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.
Files changed (166) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  3. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  5. data/lib/rigor/analysis/check_rules.rb +492 -71
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  8. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  9. data/lib/rigor/analysis/fact_store.rb +5 -4
  10. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  13. data/lib/rigor/analysis/runner.rb +17 -6
  14. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  15. data/lib/rigor/analysis/worker_session.rb +10 -14
  16. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  17. data/lib/rigor/cache/store.rb +5 -3
  18. data/lib/rigor/cli/annotate_command.rb +28 -7
  19. data/lib/rigor/cli/baseline_command.rb +4 -3
  20. data/lib/rigor/cli/check_command.rb +115 -16
  21. data/lib/rigor/cli/coverage_command.rb +148 -16
  22. data/lib/rigor/cli/coverage_scan.rb +57 -0
  23. data/lib/rigor/cli/explain_command.rb +2 -0
  24. data/lib/rigor/cli/lsp_command.rb +3 -7
  25. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  26. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  27. data/lib/rigor/cli/options.rb +9 -0
  28. data/lib/rigor/cli/plugins_command.rb +2 -1
  29. data/lib/rigor/cli/protection_renderer.rb +63 -0
  30. data/lib/rigor/cli/protection_report.rb +68 -0
  31. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  32. data/lib/rigor/cli/trace_command.rb +2 -1
  33. data/lib/rigor/cli/triage_command.rb +2 -1
  34. data/lib/rigor/cli/type_of_command.rb +1 -1
  35. data/lib/rigor/cli/type_scan_command.rb +2 -1
  36. data/lib/rigor/cli.rb +3 -2
  37. data/lib/rigor/configuration/dependencies.rb +2 -4
  38. data/lib/rigor/configuration.rb +45 -7
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  40. data/lib/rigor/environment/class_registry.rb +4 -3
  41. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  42. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  43. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  44. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  45. data/lib/rigor/environment/rbs_loader.rb +49 -5
  46. data/lib/rigor/environment.rb +17 -7
  47. data/lib/rigor/flow_contribution/fact.rb +1 -1
  48. data/lib/rigor/flow_contribution.rb +3 -5
  49. data/lib/rigor/inference/acceptance.rb +17 -9
  50. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  51. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  52. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  53. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  54. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  55. data/lib/rigor/inference/expression_typer.rb +20 -28
  56. data/lib/rigor/inference/hkt_body.rb +8 -11
  57. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  58. data/lib/rigor/inference/hkt_registry.rb +10 -11
  59. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  60. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +156 -21
  61. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  62. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  63. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  64. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  65. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  66. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  67. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  68. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  69. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  70. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  71. data/lib/rigor/inference/mutation_widening.rb +5 -11
  72. data/lib/rigor/inference/narrowing.rb +14 -16
  73. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  74. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  75. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  76. data/lib/rigor/inference/protection_scanner.rb +86 -0
  77. data/lib/rigor/inference/scope_indexer.rb +129 -55
  78. data/lib/rigor/inference/statement_evaluator.rb +244 -114
  79. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  80. data/lib/rigor/inference/synthetic_method.rb +7 -7
  81. data/lib/rigor/language_server/completion_provider.rb +6 -12
  82. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  83. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  84. data/lib/rigor/language_server/hover_provider.rb +2 -3
  85. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  86. data/lib/rigor/language_server/server.rb +9 -17
  87. data/lib/rigor/language_server.rb +4 -5
  88. data/lib/rigor/plugin/base.rb +10 -8
  89. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  90. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  91. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  92. data/lib/rigor/plugin/macro.rb +4 -5
  93. data/lib/rigor/plugin/manifest.rb +45 -66
  94. data/lib/rigor/plugin/registry.rb +6 -7
  95. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  96. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  97. data/lib/rigor/protection/mutator.rb +246 -0
  98. data/lib/rigor/rbs_extended.rb +24 -36
  99. data/lib/rigor/reflection.rb +4 -7
  100. data/lib/rigor/scope/discovery_index.rb +14 -2
  101. data/lib/rigor/scope.rb +54 -11
  102. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  103. data/lib/rigor/sig_gen/writer.rb +40 -2
  104. data/lib/rigor/source/constant_path.rb +62 -0
  105. data/lib/rigor/source.rb +1 -0
  106. data/lib/rigor/type/bound_method.rb +2 -11
  107. data/lib/rigor/type/combinator.rb +16 -3
  108. data/lib/rigor/type/constant.rb +2 -11
  109. data/lib/rigor/type/data_class.rb +2 -11
  110. data/lib/rigor/type/data_instance.rb +2 -11
  111. data/lib/rigor/type/hash_shape.rb +2 -11
  112. data/lib/rigor/type/integer_range.rb +2 -11
  113. data/lib/rigor/type/intersection.rb +2 -11
  114. data/lib/rigor/type/nominal.rb +2 -11
  115. data/lib/rigor/type/plain_lattice.rb +37 -0
  116. data/lib/rigor/type/refined.rb +72 -13
  117. data/lib/rigor/type/singleton.rb +2 -11
  118. data/lib/rigor/type/struct_class.rb +75 -0
  119. data/lib/rigor/type/struct_instance.rb +93 -0
  120. data/lib/rigor/type/tuple.rb +5 -15
  121. data/lib/rigor/type.rb +2 -0
  122. data/lib/rigor/version.rb +1 -1
  123. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  124. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  125. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  126. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  127. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  128. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  132. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  134. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  135. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  136. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  139. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  140. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  141. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  142. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  143. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  144. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  145. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  146. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  147. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  148. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  149. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  150. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  151. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  152. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  153. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  154. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  155. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  156. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  157. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  158. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  159. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  160. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  161. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  162. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  163. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  164. data/sig/rigor/scope.rbs +9 -1
  165. data/sig/rigor/type.rbs +36 -1
  166. metadata +19 -1
@@ -3,6 +3,7 @@
3
3
  require "optionparser"
4
4
 
5
5
  require_relative "../configuration"
6
+ require_relative "options"
6
7
  require_relative "../analysis/runner"
7
8
  require_relative "../cache/store"
8
9
  require_relative "../triage"
@@ -42,7 +43,7 @@ module Rigor
42
43
  options = { config: nil, format: "text", top: 10, sections: DEFAULT_SECTIONS }
43
44
  OptionParser.new do |opts|
44
45
  opts.banner = USAGE
45
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
46
+ Options.add_config(opts, options)
46
47
  opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
47
48
  opts.on("--top=N", Integer, "Hotspot-file count (default 10)") { |v| options[:top] = v }
48
49
  opts.on("--hints-only", "Print only the heuristic-hints section") { options[:sections] = %i[hints] }
@@ -53,7 +53,7 @@ module Rigor
53
53
  opts.banner = USAGE
54
54
  opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
55
55
  opts.on("--trace", "Record fail-soft fallbacks via FallbackTracer") { options[:trace] = true }
56
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
56
+ Options.add_config(opts, options)
57
57
  Options.add_editor_mode(opts, options)
58
58
  end
59
59
  parser.parse!(@argv)
@@ -4,6 +4,7 @@ require "optionparser"
4
4
  require "prism"
5
5
 
6
6
  require_relative "../configuration"
7
+ require_relative "options"
7
8
  require_relative "../environment"
8
9
  require_relative "../inference/coverage_scanner"
9
10
  require_relative "../scope"
@@ -46,7 +47,7 @@ module Rigor
46
47
  parser = OptionParser.new do |opts|
47
48
  opts.banner = USAGE
48
49
  opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
49
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
50
+ Options.add_config(opts, options)
50
51
  opts.on("--limit=N", Integer, "Max example events to print (text only)") do |value|
51
52
  options[:limit] = value
52
53
  end
data/lib/rigor/cli.rb CHANGED
@@ -151,8 +151,9 @@ module Rigor
151
151
  # - target_ruby: minimum Ruby version your project targets.
152
152
  # - paths: directories scanned by `rigor check` and
153
153
  # `rigor type-scan` when no path is given.
154
- # - plugins: reserved for future plugin contributions
155
- # (no plugins are loaded today).
154
+ # - plugins: opt-in list of plugin gem names to load.
155
+ # See https://github.com/rigortype/rigor/tree/main/plugins
156
+ # for production plugins (rigor-activerecord, rigor-sorbet, …).
156
157
  # - disable: list of `rigor check` rule identifiers to
157
158
  # silence project-wide. The shipped rules are
158
159
  # call.undefined-method, call.wrong-arity,
@@ -9,10 +9,8 @@ module Rigor
9
9
  # inference instead of degrading to `Dynamic[top]` at the
10
10
  # dependency boundary.
11
11
  #
12
- # Slice 1 lands the parser only `Configuration#dependencies`
13
- # is read, but no analyzer machinery consumes it yet. Slice 2
14
- # wires `Analysis::DependencySourceInference` against this
15
- # value object.
12
+ # Parser for the `dependencies:` YAML section; consumed by
13
+ # `Analysis::DependencySourceInference` (ADR-10).
16
14
  class Dependencies
17
15
  # Walking modes per
18
16
  # [ADR-10 § "Decision"](../../../docs/adr/10-dependency-source-inference.md#decision).
@@ -233,6 +233,9 @@ module Rigor
233
233
  # included files first (in declaration order), then the
234
234
  # current file's own keys override. Relative paths inside
235
235
  # each file are resolved against that file's directory.
236
+ # Public so the CLI can run the include-aware load before
237
+ # applying `--treat-all-as-inline-rbs`'s plugin injection
238
+ # (see {CLI::CheckCommand#load_check_configuration}).
236
239
  def self.load_with_includes(path, visited: Set.new)
237
240
  absolute = File.expand_path(path)
238
241
  raise ArgumentError, "circular include: #{absolute}" if visited.include?(absolute)
@@ -328,7 +331,7 @@ module Rigor
328
331
  out["source_inference"] = left_si + right_si unless both_empty # rigor:disable flow.always-truthy-condition
329
332
  out
330
333
  end
331
- private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge,
334
+ private_class_method :merge_includes, :resolve_paths_in, :deep_merge,
332
335
  :merge_value, :merge_dependencies_hash
333
336
 
334
337
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -437,6 +440,32 @@ module Rigor
437
440
  }
438
441
  end
439
442
 
443
+ # ADR-50 § WD2 — returns a sibling Configuration whose bleeding-edge
444
+ # selection (and the derived `bleeding_edge_severity_overrides` the two
445
+ # {SeverityProfile.resolve} sites consult) is replaced by `value`,
446
+ # leaving every other field shared with the receiver. `value` takes the
447
+ # same forms as the `bleeding_edge:` config key — `false` / `true` / a
448
+ # feature-id Array / `{ "all" => true, "except" => [...] }` — and is
449
+ # normalised through the same {#coerce_bleeding_edge} path, so an unknown
450
+ # id stays inert.
451
+ #
452
+ # The CLI's `--bleeding-edge[=ids]` / `--no-bleeding-edge` flag uses this
453
+ # to override the configured selection for a single run (the same
454
+ # CLI-over-config precedence `--workers` and `--no-cache` follow). It is a
455
+ # frozen `dup` with the two bleeding-edge ivars re-set: `dup` returns an
456
+ # unfrozen shallow copy (every other ivar is the receiver's deeply-frozen
457
+ # value, safe to share read-only), the two replacements are themselves
458
+ # deeply frozen, and the re-`freeze` keeps the result `Ractor.shareable?`
459
+ # for the worker path.
460
+ def with_bleeding_edge(value)
461
+ selector = coerce_bleeding_edge(value)
462
+ copy = dup
463
+ copy.instance_variable_set(:@bleeding_edge, selector)
464
+ copy.instance_variable_set(:@bleeding_edge_severity_overrides,
465
+ BleedingEdge.severity_overrides_for(selector))
466
+ copy.freeze
467
+ end
468
+
440
469
  private
441
470
 
442
471
  # ADR-17 slice 4 — `pre_eval:` glob expansion. Each entry is
@@ -499,10 +528,9 @@ module Rigor
499
528
  s.dup.freeze
500
529
  end
501
530
 
502
- # Slice 2 only accepts `:disabled` for the network policy. The
503
- # YAML scalar may arrive as a String (`"disabled"`) or already
504
- # as the Symbol; coerce to the canonical Symbol shape so the
505
- # downstream `TrustPolicy` constructor stays strict.
531
+ # YAML scalar may arrive as a String or already as a Symbol;
532
+ # coerce to the canonical Symbol shape so the downstream
533
+ # `TrustPolicy` constructor stays strict.
506
534
  #
507
535
  # The accepted set is duplicated from
508
536
  # {Rigor::Plugin::TrustPolicy::VALID_NETWORK_POLICIES} so
@@ -593,7 +621,7 @@ module Rigor
593
621
  case value
594
622
  when nil, false then { "mode" => "none" }
595
623
  when true then { "mode" => "all" }
596
- when Array then { "mode" => "list", "ids" => value.map(&:to_s).freeze }
624
+ when Array then { "mode" => "list", "ids" => freeze_ids(value) }
597
625
  when Hash then coerce_bleeding_edge_hash(value)
598
626
  else
599
627
  raise ArgumentError,
@@ -605,12 +633,22 @@ module Rigor
605
633
  def coerce_bleeding_edge_hash(value)
606
634
  hash = value.to_h { |k, v| [k.to_s, v] }
607
635
  if hash.fetch("all", false) == true
608
- { "mode" => "all", "except" => Array(hash["except"]).map(&:to_s).freeze }
636
+ { "mode" => "all", "except" => freeze_ids(Array(hash["except"])) }
609
637
  else
610
638
  { "mode" => "none" }
611
639
  end
612
640
  end
613
641
 
642
+ # Feature ids reach `coerce_bleeding_edge` from YAML, a `to_h` round-trip,
643
+ # or the CLI's `--bleeding-edge=a,b` (all runtime-created, non-frozen
644
+ # Strings). Each id String is frozen so the normalized selector — and thus
645
+ # the whole Configuration — stays deeply frozen and `Ractor.shareable?`
646
+ # for the worker path (the same invariant `#initialize`'s final `freeze`
647
+ # upholds for every other field).
648
+ def freeze_ids(ids)
649
+ ids.map { |id| id.to_s.dup.freeze }.freeze
650
+ end
651
+
614
652
  # Renders the normalized selector back into the user-facing
615
653
  # `bleeding_edge:` form for `#to_h` round-trips.
616
654
  def bleeding_edge_to_h
@@ -4,7 +4,7 @@ require "yaml"
4
4
 
5
5
  module Rigor
6
6
  class Environment
7
- # Open item O4 — target-project Bundler awareness.
7
+ # Target-project Bundler awareness (O4, implemented).
8
8
  #
9
9
  # Walks a Bundler-installed gem tree (e.g., the project's
10
10
  # `vendor/bundle` or a Docker-mounted bundle root) and
@@ -84,11 +84,12 @@ module Rigor
84
84
  # supplied) minus any whose `(name, version, platform)`
85
85
  # does not match a lockfile entry.
86
86
  def self.discover(bundle_path:, project_root: Dir.pwd, auto_detect: true,
87
- skip_gems: SKIPPED_GEMS_BY_DEFAULT, locked_gems: nil)
87
+ skip_gems: SKIPPED_GEMS_BY_DEFAULT, locked_gems: nil, home: nil)
88
88
  resolved = resolve_bundle_path(
89
89
  bundle_path: bundle_path,
90
90
  project_root: project_root,
91
- auto_detect: auto_detect
91
+ auto_detect: auto_detect,
92
+ home: home
92
93
  )
93
94
  return [] if resolved.nil?
94
95
 
@@ -143,7 +144,7 @@ module Rigor
143
144
  # Returns `Pathname` resolved bundle path, or `nil` when
144
145
  # neither explicit nor auto-detected. Public for the stats
145
146
  # banner so end users can see what rigor picked up.
146
- def self.resolve_bundle_path(bundle_path:, project_root: Dir.pwd, auto_detect: true)
147
+ def self.resolve_bundle_path(bundle_path:, project_root: Dir.pwd, auto_detect: true, home: nil)
147
148
  if bundle_path
148
149
  path = Pathname.new(File.expand_path(bundle_path.to_s, project_root))
149
150
  return path if path.directory?
@@ -153,31 +154,77 @@ module Rigor
153
154
 
154
155
  return nil unless auto_detect
155
156
 
156
- detected = auto_detect(project_root: project_root)
157
+ detected = auto_detect(project_root: project_root, home: home)
157
158
  Pathname.new(detected) if detected
158
159
  end
159
160
 
160
- # Auto-detection order:
161
+ # Auto-detection order — project-local strategies win over the
162
+ # user-global one, mirroring Bundler's own config precedence:
161
163
  # 1. `<project_root>/.bundle/config` carries `BUNDLE_PATH:`
162
164
  # set by `bundle config set --local path <dir>`.
163
165
  # 2. `<project_root>/vendor/bundle/` — the conventional
164
166
  # in-tree install location when a developer ran
165
167
  # `bundle install --path vendor/bundle`.
166
- # 3. `nil` let the caller proceed without bundle sig
168
+ # 3. The user-global bundler config `<home>/.bundle/config`
169
+ # `BUNDLE_PATH:` (`bundle config set --global path <dir>`),
170
+ # resolved relative to the project root and used only when
171
+ # it points at an existing directory — the last resort for
172
+ # a project with no in-tree bundle. Purely additive: it is
173
+ # consulted only when steps 1–2 found nothing, so it never
174
+ # changes an already-working detection.
175
+ # 4. `nil` — let the caller proceed without bundle sig
167
176
  # discovery (rigor's vendored RBS still loads).
168
- def self.auto_detect(project_root:)
169
- from_config = read_bundle_config_path(project_root)
177
+ #
178
+ # Note (ADR-27): rigor reads the *project* as data, so
179
+ # detection is limited to paths recorded in project-local or
180
+ # user-global Bundler config files. The pure-default install
181
+ # location — gems in the active Ruby's GEM_HOME with no `path`
182
+ # configured — is the *project's* Ruby's gem home, which the
183
+ # isolated analyzer cannot know without running the project's
184
+ # toolchain. Point rigor at it with `bundler.bundle_path:`, or
185
+ # supply signatures via `rbs collection install` /
186
+ # `dependencies.source_inference:`. `BUNDLE_PATH` from rigor's
187
+ # own environment is deliberately NOT consulted — it describes
188
+ # rigor's bundle, not the analyzed project's.
189
+ #
190
+ # `home:` defaults to the invoking user's home directory; it is
191
+ # a parameter so tests stay hermetic (no read of the real
192
+ # `~/.bundle/config`).
193
+ def self.auto_detect(project_root:, home: nil)
194
+ from_config = read_bundle_config_path(File.join(project_root, ".bundle", "config"))
170
195
  return File.expand_path(from_config, project_root) if from_config
171
196
 
172
197
  vendor = File.join(project_root, "vendor", "bundle")
173
198
  return vendor if File.directory?(vendor)
174
199
 
200
+ global_bundle_path(project_root: project_root, home: home)
201
+ end
202
+
203
+ # Resolves the user-global `BUNDLE_PATH` against the project
204
+ # root, returning it only when it is an existing directory.
205
+ # Returns nil when there is no global config, no home, or the
206
+ # configured path does not exist.
207
+ def self.global_bundle_path(project_root:, home:)
208
+ home ||= default_home
209
+ return nil if home.nil?
210
+
211
+ configured = read_bundle_config_path(File.join(home, ".bundle", "config"))
212
+ return nil unless configured
213
+
214
+ resolved = File.expand_path(configured, project_root)
215
+ File.directory?(resolved) ? resolved : nil
216
+ end
217
+ private_class_method :global_bundle_path
218
+
219
+ def self.default_home
220
+ Dir.home
221
+ rescue StandardError
175
222
  nil
176
223
  end
224
+ private_class_method :default_home
177
225
 
178
- def self.read_bundle_config_path(project_root)
179
- config_path = File.join(project_root, ".bundle", "config")
180
- return nil unless File.exist?(config_path)
226
+ def self.read_bundle_config_path(config_path)
227
+ return nil unless config_path && File.exist?(config_path)
181
228
 
182
229
  # `.bundle/config` is YAML with all-caps env-style keys.
183
230
  # `BUNDLE_PATH:` is the canonical key (Bundler 2.x); the
@@ -185,7 +232,8 @@ module Rigor
185
232
  data = YAML.safe_load_file(config_path)
186
233
  return nil unless data.is_a?(Hash)
187
234
 
188
- data["BUNDLE_PATH"]
235
+ path = data["BUNDLE_PATH"]
236
+ path && !path.to_s.empty? ? path.to_s : nil
189
237
  rescue StandardError
190
238
  # Malformed `.bundle/config` should not break analysis;
191
239
  # silently skip auto-detection.
@@ -7,8 +7,8 @@ module Rigor
7
7
  # Resolves Ruby Class/Module objects to Rigor::Type::Nominal instances.
8
8
  # The hardcoded list spans the core classes the literal typer (Slice 1)
9
9
  # and the constant-resolution path (Slice 2 strengthening) need.
10
- # Slice 4 will extend the registry by reading RBS Definitions through
11
- # Rigor::Environment::RbsLoader.
10
+ # This is the static fast path for built-ins; `Environment` falls through
11
+ # to `RbsLoader` for all other names — the two tiers are complementary.
12
12
  #
13
13
  # See docs/internal-spec/inference-engine.md for the binding contract
14
14
  # (every entry below MUST always be recognised).
@@ -28,7 +28,8 @@ module Rigor
28
28
  # Common Ruby core classes that user code routinely names by constant
29
29
  # reference. Adding them to the registry lets `nominal_for_name`
30
30
  # resolve `Array`, `Hash`, etc. without each call site re-listing
31
- # them; Slice 4's RBS loader will subsume these once it lands.
31
+ # them. The static registry is the cheap fast path; `RbsLoader`
32
+ # extends coverage to all other RBS-declared names (Slice 4, landed).
32
33
  SLICE_2_BUILT_INS = [
33
34
  Array,
34
35
  Hash,
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Environment
5
+ # Per-key memoization container for {Environment#constant_for_name}.
6
+ # Held by {Environment} so the otherwise-frozen instance can cache
7
+ # constant-resolution results on first access — the sibling of
8
+ # {HktRegistryHolder}, generalised from one slot to a name-keyed map.
9
+ #
10
+ # Why this exists: `constant_for_name` is a pure function of its
11
+ # name for a given Environment (both the predefined-constant
12
+ # refinement table and the RBS loader's constant table are fixed for
13
+ # the Environment's lifetime), yet it is the hottest non-dispatch
14
+ # path on a large Rails app. {ExpressionTyper#resolve_constant_name}
15
+ # peels a lexical-candidate ladder per constant reference, so the
16
+ # same qualified names recur thousands of times across files — and
17
+ # each miss runs a `const_get` walk that raises + rescues `NameError`
18
+ # for every project-defined constant (the common case). Caching the
19
+ # result collapses the repeats to a hash lookup.
20
+ #
21
+ # Caches `nil` results too (a name that resolves to no refined /
22
+ # RBS constant type stays unresolved for the run), so the
23
+ # const_get-and-rescue is paid at most once per distinct name.
24
+ #
25
+ # Concurrency: single-threaded use only, the same discipline as
26
+ # {HktRegistryHolder} — the Ractor pool builds a per-worker
27
+ # Environment and the LSP single-publish invariant serialises the
28
+ # shared-Environment reader path. If a future caller introduces a
29
+ # multi-threaded reader against one Environment, the synchronisation
30
+ # belongs at that caller's seam, not here.
31
+ class ConstantTypeCacheHolder
32
+ def initialize
33
+ @cache = {}
34
+ end
35
+
36
+ def fetch(key)
37
+ return @cache[key] if @cache.key?(key)
38
+
39
+ @cache[key] = yield
40
+ end
41
+ end
42
+ end
43
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Rigor
4
4
  class Environment
5
- # Open item O4 Layer 3 — Gemfile.lock parse.
5
+ # Gemfile.lock parser for target-project bundler awareness (O4 Layer 3, implemented).
6
6
  #
7
7
  # Parses a target project's `Gemfile.lock` via Bundler's
8
8
  # `LockfileParser` and exposes the locked gem set as a frozen
@@ -4,8 +4,7 @@ require "yaml"
4
4
 
5
5
  module Rigor
6
6
  class Environment
7
- # Open item O4 Layer 3 slice 2 — `rbs collection install`
8
- # awareness.
7
+ # `rbs collection install` awareness (O4 Layer 3 slice 2, implemented).
9
8
  #
10
9
  # When the target project has been set up with `rbs
11
10
  # collection install` (the standard RBS-ecosystem flow for
@@ -42,7 +42,8 @@ module Rigor
42
42
  # enough that hard-coding is acceptable; a directory walk
43
43
  # at every call would add stat-cost to no benefit.)
44
44
  VENDORED_GEM_NAMES = Set[
45
- "bcrypt", "bundler", "cgi", "did_you_mean", "idn-ruby", "mysql2", "nokogiri", "pg", "prism", "redis", "rubygems"
45
+ "ast", "bcrypt", "bundler", "cgi", "did_you_mean",
46
+ "idn-ruby", "mysql2", "nokogiri", "pg", "prism", "redis", "rubygems"
46
47
  ].freeze
47
48
 
48
49
  # @param locked_gems [Hash{String => LockfileResolver::LockedGem}]
@@ -363,11 +363,11 @@ module Rigor
363
363
  # out at build time so the loader stays robust to fixtures and
364
364
  # bare repositories.
365
365
  # @param cache_store [Rigor::Cache::Store, nil] the persistent
366
- # cache the loader consults for translated constant lookups
367
- # (and, in later v0.0.9 slices, other Marshal-clean
368
- # reflection artefacts). Pass `nil` (the default) to skip
369
- # the cache entirely; the runner threads its own Store
370
- # through here when caching is enabled.
366
+ # cache the loader threads through to `RbsEnvironment`,
367
+ # `RbsKnownClassNames`, `RbsConstantTable`,
368
+ # `RbsClassAncestorTable`, and `RbsClassTypeParamNames`
369
+ # producers. Pass `nil` (the default) to skip caching; the
370
+ # runner threads its own Store through here when enabled.
371
371
  # @param virtual_rbs [Array<[String, String]>] ADR-32 WD4 —
372
372
  # `[virtual_filename, rbs_source]` pairs synthesised from
373
373
  # project source by a plugin's
@@ -643,6 +643,29 @@ module Rigor
643
643
  interface_definition(interface_name)&.methods&.keys
644
644
  end
645
645
 
646
+ # @param rbs_alias [RBS::Types::Alias] a type-alias reference (`string`,
647
+ # `int`, `range[int?]`, …) appearing in a method signature.
648
+ # @return [RBS::Types::t, nil] the alias's aliased type one level out,
649
+ # with type arguments substituted for a generic alias (`string` →
650
+ # `::String | ::_ToStr`; `range[int?]` → `::Range[int?] |
651
+ # ::_Range[int?]`), or nil for an unresolved name. Lets a caller see
652
+ # through the alias that {Inference::RbsTypeTranslator} otherwise
653
+ # degrades to `untyped`, which is why an interface/alias parameter
654
+ # does not reject `nil`. `expand_alias2` handles the (rarer) generic
655
+ # case — a `range[T]` param previously fell back to "admits", which
656
+ # suppressed e.g. `MatchData#[](nil)`.
657
+ def expand_type_alias(rbs_alias)
658
+ return nil if env.nil?
659
+
660
+ name = rbs_alias.name
661
+ name = name.absolute! unless name.absolute?
662
+ return nil unless env.type_alias_decls.key?(name)
663
+
664
+ builder.expand_alias2(name, rbs_alias.args)
665
+ rescue ::RBS::BaseError, StandardError
666
+ nil
667
+ end
668
+
646
669
  # @return [RBS::Definition, nil] the resolved singleton (class
647
670
  # object) definition for `class_name`. The methods on this
648
671
  # definition are the *class methods* of `class_name`, including
@@ -1034,6 +1057,8 @@ module Rigor
1034
1057
  rbs_name = parse_type_name(class_name)
1035
1058
  return nil unless rbs_name
1036
1059
  return nil if env.nil?
1060
+
1061
+ rbs_name = canonical_module_name(rbs_name)
1037
1062
  return nil unless env.class_decls.key?(rbs_name)
1038
1063
 
1039
1064
  builder.build_instance(rbs_name)
@@ -1045,6 +1070,8 @@ module Rigor
1045
1070
  rbs_name = parse_type_name(class_name)
1046
1071
  return nil unless rbs_name
1047
1072
  return nil if env.nil?
1073
+
1074
+ rbs_name = canonical_module_name(rbs_name)
1048
1075
  return nil unless env.class_decls.key?(rbs_name)
1049
1076
 
1050
1077
  builder.build_singleton(rbs_name)
@@ -1052,6 +1079,23 @@ module Rigor
1052
1079
  nil
1053
1080
  end
1054
1081
 
1082
+ # Resolve an RBS class/module ALIAS to its canonical declared name.
1083
+ # `class Mutex = Thread::Mutex` lives only in `class_alias_decls`, so
1084
+ # `class_known?` reports it (it checks that table) but the definition
1085
+ # builder — which only knows `class_decls` — could not enumerate its
1086
+ # methods, leaving alias classes (`Mutex`, and any `X = Y`) with no
1087
+ # resolvable method surface. Normalising via the env (RBS's own alias
1088
+ # resolution) before the `class_decls` guard fixes dispatch AND the
1089
+ # `call.undefined-method` existence check on them. A non-alias name, or
1090
+ # one that does not normalise, is returned unchanged.
1091
+ def canonical_module_name(rbs_name)
1092
+ return rbs_name unless env.class_alias_decls.key?(rbs_name)
1093
+
1094
+ env.normalize_module_name?(rbs_name) || rbs_name
1095
+ rescue ::RBS::BaseError
1096
+ rbs_name
1097
+ end
1098
+
1055
1099
  # Memoised on `@state` (the per-loader store also holding `:env` /
1056
1100
  # `:builder`): `RBS::TypeName.parse` is a pure, deterministic
1057
1101
  # function of the normalised string, and the `RBS::TypeName` it
@@ -7,6 +7,7 @@ require_relative "environment/rbs_loader"
7
7
  require_relative "environment/reflection"
8
8
  require_relative "environment/reporters"
9
9
  require_relative "environment/hkt_registry_holder"
10
+ require_relative "environment/constant_type_cache_holder"
10
11
  require_relative "environment/bundle_sig_discovery"
11
12
  require_relative "environment/lockfile_resolver"
12
13
  require_relative "environment/rbs_collection_discovery"
@@ -15,12 +16,13 @@ require_relative "inference/synthetic_method_index"
15
16
  require_relative "inference/project_patched_methods"
16
17
  require_relative "inference/hkt_registry"
17
18
  require_relative "builtins/hkt_builtins"
19
+ require_relative "builtins/predefined_constant_refinements"
18
20
  require_relative "type_node/name_scope"
19
21
  require_relative "type_node/resolver_chain"
20
22
 
21
23
  module Rigor
22
24
  # The engine's view of the type universe outside the current scope.
23
- # Slice 1 only exposed the class registry; Slice 4 adds the RBS loader,
25
+ # Slice 1 exposed only the class registry; Slice 4 added the RBS loader,
24
26
  # which threads through ExpressionTyper and MethodDispatcher to type
25
27
  # constant references and method calls that the literal-typer and
26
28
  # constant-folding tiers cannot answer.
@@ -123,6 +125,7 @@ module Rigor
123
125
  # build at all.
124
126
  @hkt_registry_base = hkt_registry || Inference::HktRegistry::EMPTY
125
127
  @hkt_registry_holder = HktRegistryHolder.new
128
+ @constant_type_cache = ConstantTypeCacheHolder.new
126
129
  @name_scope = build_name_scope
127
130
  freeze
128
131
  end
@@ -453,11 +456,11 @@ module Rigor
453
456
  def invoke_synthesizer_safely(callable, path)
454
457
  callable.call(path.to_s)
455
458
  rescue StandardError
456
- # WD6 fail-soft — a synthesizer that raises does NOT
457
- # crash analysis. Slice 2b will turn this into a
458
- # `source-rbs-synthesis-failed` info diagnostic; for now
459
- # the contract is "no analysis crash on a misbehaving
460
- # synthesizer".
459
+ # WD6 fail-soft — a synthesizer that raises does NOT crash
460
+ # analysis. Unlike the `[:error, msg]` return path (which
461
+ # the runner surfaces as `source-rbs-synthesis-failed`),
462
+ # an unhandled raise is swallowed silently; the
463
+ # unexamined-raise channel is deliberately silent per WD6.
461
464
  nil
462
465
  end
463
466
  end
@@ -504,7 +507,14 @@ module Rigor
504
507
  def constant_for_name(name)
505
508
  return nil if rbs_loader.nil?
506
509
 
507
- rbs_loader.constant_type(name.to_s)
510
+ # Pure function of `name` for this Environment's lifetime — the
511
+ # refinement table and the RBS constant table are both fixed — so
512
+ # memoise across the lexical-candidate ladder's heavy name reuse.
513
+ key = name.to_s
514
+ @constant_type_cache.fetch(key) do
515
+ Builtins::PredefinedConstantRefinements.lookup(key) ||
516
+ rbs_loader.constant_type(key)
517
+ end
508
518
  end
509
519
 
510
520
  # Returns true when the constant name is known to either the static
@@ -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. Future plugin contributions (slice 5 emission protocol).
21
+ # 4. Plugin contributions via `type_specifier` 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}
@@ -20,11 +20,9 @@ module Rigor
20
20
  # contract — see ADR-2 § "Flow Contribution Bundle" for the binding
21
21
  # definition.
22
22
  #
23
- # The element-list flattening (`to_element_list`) ADR-2 mentions is
24
- # intentionally not implemented yet: it is the analyzer-internal
25
- # bookkeeping behind the merge policy and will land alongside the
26
- # plugin contribution merger in v0.1.0. Plugin authors should not
27
- # rely on it.
23
+ # `to_element_list` and `Merger` are implemented; plugin authors
24
+ # should not depend on the `Element` shape — the bundle is the
25
+ # public contract.
28
26
  class FlowContribution
29
27
  # Provenance carries the metadata every contribution needs for
30
28
  # diagnostic attribution and cache invalidation. `source_family`
@@ -249,6 +249,12 @@ module Rigor
249
249
  # anonymous local-bound form projects to `Data` itself).
250
250
  accepts(self_type, project_data_instance_to_nominal(other_type), mode: mode)
251
251
  .with_reason("projected DataInstance to Nominal[#{other_type.class_name || 'Data'}]")
252
+ when Type::StructInstance
253
+ # ADR-48 Struct follow-up: same projection as DataInstance — a
254
+ # class-tagged Struct value is exactly one value of its tagging
255
+ # class (the anonymous form projects to `Struct` itself).
256
+ accepts(self_type, project_struct_instance_to_nominal(other_type), mode: mode)
257
+ .with_reason("projected StructInstance to Nominal[#{other_type.class_name || 'Struct'}]")
252
258
  when Type::Difference, Type::Refined
253
259
  # A refinement carrier's value set is a subset of its
254
260
  # base. So if `self` (Nominal) accepts the base, it
@@ -386,6 +392,10 @@ module Rigor
386
392
  Type::Combinator.nominal_of(instance.class_name || "Data")
387
393
  end
388
394
 
395
+ def project_struct_instance_to_nominal(instance)
396
+ Type::Combinator.nominal_of(instance.class_name || "Struct")
397
+ end
398
+
389
399
  def project_hash_shape_to_nominal(shape)
390
400
  return Type::Combinator.nominal_of(Hash) if shape.pairs.empty?
391
401
 
@@ -822,15 +832,13 @@ module Rigor
822
832
  Type::AcceptsResult.no(mode: mode, reasons: reason)
823
833
  end
824
834
 
825
- # Slice 4 phase 2c uses Ruby's actual class hierarchy to answer
826
- # "is D a subclass of C?". This works for any class loadable
827
- # through Object.const_get -- core, stdlib, and live application
828
- # classes. When either name fails to resolve we surface "maybe":
829
- # the caller (overload selector) treats yes/maybe identically,
830
- # so the conservative answer keeps overload coverage intact.
831
- # Slice 5 will replace this with an RBS-driven hierarchy lookup
832
- # so ahead-of-time type checking no longer relies on Ruby
833
- # loading the application classes.
835
+ # Uses Ruby's actual class hierarchy via Object.const_get to answer
836
+ # "is D a subclass of C?" for core, stdlib, and application classes.
837
+ # When either name fails to resolve we surface "maybe": the caller
838
+ # (overload selector) treats yes/maybe identically, so the conservative
839
+ # answer keeps overload coverage intact. RbsHierarchy exists but this
840
+ # path does not yet consult it; migration to an RBS-driven lookup
841
+ # is deferred.
834
842
  def class_subtype_result(target_name:, actual_name:, mode:, kind:)
835
843
  return Type::AcceptsResult.yes(mode: mode, reasons: "exact name match") if target_name == actual_name
836
844
 
@@ -183,9 +183,8 @@ module Rigor
183
183
  # `|*rest|` binds an Array of the leftover positional arguments.
184
184
  # The expected-types array is per-position, not per-rest; we
185
185
  # cannot reliably pick a single element type for rest, so we
186
- # default to `Array[Dynamic[Top]]`. Slice C sub-phase 2 may
187
- # tighten this when the receiving method's RBS rest type is
188
- # available.
186
+ # default to `Array[Dynamic[Top]]`. Element-type precision for
187
+ # rest parameters is deferred (demand-gated).
189
188
  def bind_rest(params_node, bindings)
190
189
  rest = params_node.rest
191
190
  return unless rest.respond_to?(:name) && rest&.name
@@ -11,8 +11,8 @@ module Rigor
11
11
  # catalog is NOT routed through
12
12
  # `MethodDispatcher::ConstantFolding::CATALOG_BY_CLASS`
13
13
  # (which dispatches on the receiver's concrete class).
14
- # The data is consumed by future include-aware lookup —
15
- # see `docs/CURRENT_WORK.md` for the planned slice.
14
+ # The data is wired into `MODULE_CATALOGS` in
15
+ # `MethodDispatcher::ConstantFolding` (ancestor-chain lookup).
16
16
  COMPARABLE_CATALOG = MethodCatalog.for_topic(
17
17
  "comparable",
18
18
  mutating_selectors: {