rigortype 0.1.5 → 0.1.7

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -79
  3. data/lib/rigor/analysis/baseline.rb +347 -0
  4. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  5. data/lib/rigor/analysis/check_rules.rb +68 -3
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  7. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/runner.rb +309 -22
  11. data/lib/rigor/analysis/worker_session.rb +14 -2
  12. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  13. data/lib/rigor/builtins/static_return_refinements.rb +142 -0
  14. data/lib/rigor/cache/store.rb +33 -3
  15. data/lib/rigor/cli/baseline_command.rb +377 -0
  16. data/lib/rigor/cli/lsp_command.rb +129 -0
  17. data/lib/rigor/cli/type_of_command.rb +44 -5
  18. data/lib/rigor/cli.rb +142 -13
  19. data/lib/rigor/configuration.rb +58 -2
  20. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  21. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  22. data/lib/rigor/environment/rbs_loader.rb +67 -2
  23. data/lib/rigor/environment/reporters.rb +40 -0
  24. data/lib/rigor/environment.rb +119 -9
  25. data/lib/rigor/flow_contribution/fact.rb +20 -10
  26. data/lib/rigor/inference/acceptance.rb +48 -3
  27. data/lib/rigor/inference/expression_typer.rb +64 -2
  28. data/lib/rigor/inference/hkt_body.rb +171 -0
  29. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  30. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  31. data/lib/rigor/inference/hkt_registry.rb +223 -0
  32. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  33. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
  34. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +174 -6
  36. data/lib/rigor/inference/narrowing.rb +103 -1
  37. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  38. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  39. data/lib/rigor/inference/scope_indexer.rb +209 -19
  40. data/lib/rigor/inference/statement_evaluator.rb +172 -11
  41. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  42. data/lib/rigor/language_server/buffer_table.rb +63 -0
  43. data/lib/rigor/language_server/completion_provider.rb +438 -0
  44. data/lib/rigor/language_server/debouncer.rb +86 -0
  45. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  46. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  47. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  48. data/lib/rigor/language_server/hover_provider.rb +74 -0
  49. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  50. data/lib/rigor/language_server/loop.rb +71 -0
  51. data/lib/rigor/language_server/project_context.rb +145 -0
  52. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  53. data/lib/rigor/language_server/server.rb +384 -0
  54. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  55. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  56. data/lib/rigor/language_server/uri.rb +40 -0
  57. data/lib/rigor/language_server.rb +29 -0
  58. data/lib/rigor/plugin/base.rb +63 -0
  59. data/lib/rigor/plugin/macro/heredoc_template.rb +127 -13
  60. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  61. data/lib/rigor/plugin/manifest.rb +54 -7
  62. data/lib/rigor/plugin/registry.rb +19 -0
  63. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  64. data/lib/rigor/rbs_extended.rb +82 -2
  65. data/lib/rigor/sig_gen/generator.rb +12 -3
  66. data/lib/rigor/type/app.rb +107 -0
  67. data/lib/rigor/type.rb +1 -0
  68. data/lib/rigor/version.rb +1 -1
  69. data/sig/rigor/environment.rbs +10 -4
  70. data/sig/rigor/inference.rbs +2 -0
  71. data/sig/rigor.rbs +4 -1
  72. metadata +56 -1
@@ -228,10 +228,26 @@ module Rigor
228
228
  def ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
229
229
  return [] if writes.size < 2
230
230
 
231
- first_class = ivar_class_for(writes.first[:type])
231
+ # Skip past leading `NilClass` writes when establishing
232
+ # the canonical type. The common nullable-slot idiom
233
+ # (`@x = nil` placeholder in `initialize` / a default
234
+ # state slot, then `@x = :foo` on first concrete state)
235
+ # would otherwise fire a false positive on every
236
+ # concrete write because `first_class` was `NilClass`
237
+ # and every subsequent `Symbol` / `String` / `Hash`
238
+ # write triggered the divergence rule. The first
239
+ # concrete (non-nil) write is the canonical type;
240
+ # additional `NilClass` writes are still tolerated
241
+ # downstream by the existing `other_class == "NilClass"`
242
+ # check (the nullable-slot resets to nil between work).
243
+ canonical = writes.find { |w| ivar_class_for(w[:type]) != "NilClass" }
244
+ return [] if canonical.nil?
245
+
246
+ first_class = ivar_class_for(canonical[:type])
232
247
  return [] if first_class.nil?
233
248
 
234
- writes[1..].filter_map do |write|
249
+ canonical_index = writes.index(canonical)
250
+ writes[(canonical_index + 1)..].filter_map do |write|
235
251
  other_class = ivar_class_for(write[:type])
236
252
  next nil if other_class.nil? || other_class == "NilClass" || other_class == first_class
237
253
 
@@ -358,9 +374,27 @@ module Rigor
358
374
  method_def = lookup_method(receiver_type, class_name, call_node.name, scope)
359
375
  return nil if method_def
360
376
 
377
+ # Module-mixin fallback (mirror of
378
+ # `MethodDispatcher#user_class_fallback_receiver`'s module
379
+ # path): an instance method on a module-mixin like
380
+ # `PP::ObjectMixin` observes Kernel / Object methods
381
+ # through every concrete includer's ancestor chain, so an
382
+ # unresolved `self.inspect` / `self.respond_to?` /
383
+ # `self.class` MUST NOT fire `undefined-method`. Retry
384
+ # against Object before the rule fires.
385
+ return nil if module_mixin_receiver?(receiver_type, scope) &&
386
+ lookup_method(receiver_type, "Object", call_node.name, scope)
387
+
361
388
  build_undefined_method_diagnostic(path, call_node, receiver_type)
362
389
  end
363
390
 
391
+ def module_mixin_receiver?(receiver_type, scope)
392
+ return false unless receiver_type.is_a?(Type::Nominal)
393
+ return false if scope.environment.nil?
394
+
395
+ scope.environment.rbs_module?(receiver_type.class_name)
396
+ end
397
+
364
398
  # Returns a qualified class name for the in-scope check.
365
399
  # Nominal / Singleton carry a single-class identity
366
400
  # directly. Constant projects to its value's class.
@@ -944,8 +978,18 @@ module Rigor
944
978
  # / Constant / Tuple / HashShape; the wrapper exists so
945
979
  # the ivar rule can extend the envelope (or apply
946
980
  # different filters) without disturbing the call rules.
981
+ #
982
+ # `TrueClass` / `FalseClass` are both normalised to
983
+ # `"bool"` here so the common boolean-flag idiom
984
+ # (`@loaded = false` in `initialize` then `@loaded = true`
985
+ # on first work) doesn't fire the mismatch rule. A real
986
+ # `bool → String` drift still trips because the second
987
+ # write's `ivar_class_for` returns `"String"`.
947
988
  def ivar_class_for(type)
948
- concrete_class_name(type)
989
+ name = concrete_class_name(type)
990
+ return "bool" if %w[TrueClass FalseClass].include?(name)
991
+
992
+ name
949
993
  end
950
994
 
951
995
  def build_always_truthy_condition_diagnostic(path, predicate_node, polarity)
@@ -1032,8 +1076,29 @@ module Rigor
1032
1076
  # (no splat / kw / block-pass / forwarded).
1033
1077
  # - Per-argument: skip when EITHER side is `Dynamic`
1034
1078
  # (the call cannot be statically refuted).
1079
+ # Ruby's universal-equality methods accept any object
1080
+ # per the `Object#==(other) → bool` /
1081
+ # `Object#eql?(other) → bool` contract. Even when a
1082
+ # subclass overrides `==` to compare specific shapes
1083
+ # (URI::Generic#==(URI::Generic), Time#==(Time), …),
1084
+ # the runtime convention is to RETURN false for
1085
+ # type-mismatched arguments rather than raise. RBS sigs
1086
+ # that declare a tight parameter type therefore over-
1087
+ # specify; checking arguments against them produces
1088
+ # spurious mismatches such as
1089
+ # `URI::Generic#==(URI::Generic)`
1090
+ # called with `URI::HTTP | nil`
1091
+ # tdiary-core's `config_uri == referer_uri` (where
1092
+ # `referer_uri` is `URI.parse(...) if condition`, hence
1093
+ # union-with-nil) is the canonical case. Skip arg
1094
+ # checking on these methods entirely; the call is
1095
+ # well-formed by Ruby's contract.
1096
+ UNIVERSAL_EQUALITY_METHODS = %i[== != eql? equal? <=>].to_set.freeze
1097
+ private_constant :UNIVERSAL_EQUALITY_METHODS
1098
+
1035
1099
  def argument_type_diagnostic(path, call_node, scope_index)
1036
1100
  return nil if call_node.receiver.nil?
1101
+ return nil if UNIVERSAL_EQUALITY_METHODS.include?(call_node.name)
1037
1102
  return nil unless plain_positional_call?(call_node)
1038
1103
 
1039
1104
  scope = scope_index[call_node]
@@ -54,7 +54,7 @@ module Rigor
54
54
  )
55
55
  @resolved_gems = resolved_gems.freeze
56
56
  @unresolvable = unresolvable.freeze
57
- @method_catalog = method_catalog.freeze
57
+ @method_catalog = normalize_catalog(method_catalog).freeze
58
58
  @budget_exceeded = budget_exceeded.freeze
59
59
  @class_to_gem = class_to_gem.freeze
60
60
  @budget_overrun_strategy = budget_overrun_strategy
@@ -62,6 +62,19 @@ module Rigor
62
62
  freeze
63
63
  end
64
64
 
65
+ # Accepts both the post-heuristic `CatalogEntry` shape
66
+ # (the Walker's current output) and the legacy bare-Symbol
67
+ # `:instance` / `:singleton` shape (older tests + earlier
68
+ # in-tree callers). Symbol values are wrapped into
69
+ # `CatalogEntry(kind: value, return_type: nil)` so the
70
+ # downstream dispatcher always sees a single shape.
71
+ def normalize_catalog(catalog)
72
+ catalog.transform_values do |value|
73
+ value.is_a?(Symbol) ? Walker::CatalogEntry.new(kind: value) : value
74
+ end
75
+ end
76
+ private :normalize_catalog
77
+
65
78
  # @return [String, nil] the gem that owns `class_name`
66
79
  # (first-write-wins); `nil` when the class isn't in
67
80
  # any opt-in gem's catalog.
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../type"
6
+
7
+ module Rigor
8
+ module Analysis
9
+ module DependencySourceInference
10
+ # Walker enhancement (ADR-10 § "Open questions"): pulls a
11
+ # **heuristic** return type out of a method body's tail
12
+ # expression. The heuristic is intentionally narrow — only
13
+ # the trivially-decidable shapes contribute. Everything
14
+ # else returns `nil`, which the dispatcher consumes as
15
+ # "fall back to `Dynamic[top]`" (the pre-enhancement
16
+ # behaviour).
17
+ #
18
+ # The contract is a strict floor:
19
+ #
20
+ # - Last statement is a literal scalar (Integer / Float /
21
+ # Symbol / true / false / nil) → `Constant<value>`.
22
+ # - Last statement is a String literal → `Nominal[String]`
23
+ # (NOT `Constant<"x">`; a String literal is mutable under
24
+ # `# frozen_string_literal: false`, so the analyzer cannot
25
+ # claim object identity).
26
+ # - Last statement is an Array / Hash literal →
27
+ # `Nominal[Array]` / `Nominal[Hash]` (element-type
28
+ # inference stays deferred — too expensive for the
29
+ # heuristic tier).
30
+ # - Last statement is `self` → `nil` (the caller's receiver
31
+ # nominal would be more accurate but the walker doesn't
32
+ # carry the receiver context; deferred).
33
+ # - Anything else → `nil`.
34
+ #
35
+ # The dispatcher wraps the returned type in `Dynamic[T]`
36
+ # before returning to the user per ADR-10's `Dynamic`-origin
37
+ # contract. The wrapping is the dispatcher's responsibility,
38
+ # not the heuristic's.
39
+ module ReturnTypeHeuristic
40
+ module_function
41
+
42
+ # @param def_node [Prism::DefNode]
43
+ # @return [Rigor::Type, nil] heuristic return type, or
44
+ # `nil` when the body's tail expression doesn't match
45
+ # any of the recognised shapes.
46
+ def extract(def_node)
47
+ body = def_node.body
48
+ return nil if body.nil?
49
+
50
+ tail = tail_expression(body)
51
+ literal_return_type(tail)
52
+ end
53
+
54
+ # Extracts the last evaluated expression of the method
55
+ # body. A `Prism::DefNode`'s body is either a
56
+ # `StatementsNode` (multi-statement body) or a single
57
+ # expression node directly. We dig past `BeginNode` /
58
+ # rescue wrappers to the protected body's tail.
59
+ def tail_expression(node)
60
+ case node
61
+ when Prism::StatementsNode
62
+ tail_expression(node.body.last) unless node.body.empty?
63
+ when Prism::BeginNode
64
+ tail_expression(node.statements) if node.statements
65
+ when nil
66
+ nil
67
+ else
68
+ node
69
+ end
70
+ end
71
+ private_class_method :tail_expression
72
+
73
+ # The per-shape heuristic. Per the module docstring,
74
+ # immutable scalar literals fold to `Constant<value>`;
75
+ # mutable container literals (String, Array, Hash) fold
76
+ # to the appropriate Nominal; everything else returns
77
+ # nil.
78
+ def literal_return_type(node)
79
+ case node
80
+ when Prism::IntegerNode, Prism::FloatNode then Type::Combinator.constant_of(node.value)
81
+ when Prism::SymbolNode then symbol_constant(node)
82
+ when Prism::TrueNode then Type::Combinator.constant_of(true)
83
+ when Prism::FalseNode then Type::Combinator.constant_of(false)
84
+ when Prism::NilNode then Type::Combinator.constant_of(nil)
85
+ when Prism::StringNode then Type::Combinator.nominal_of("String")
86
+ when Prism::ArrayNode then Type::Combinator.nominal_of("Array")
87
+ when Prism::HashNode then Type::Combinator.nominal_of("Hash")
88
+ end
89
+ end
90
+ private_class_method :literal_return_type
91
+
92
+ # `Prism::SymbolNode#value` returns the Symbol's name as
93
+ # a String. We `.to_sym` it for the Constant carrier so
94
+ # `:foo` is `Constant<:foo>`, not `Constant<"foo">`.
95
+ def symbol_constant(node)
96
+ value = node.value
97
+ return nil if value.nil?
98
+
99
+ Type::Combinator.constant_of(value.to_sym)
100
+ end
101
+ private_class_method :symbol_constant
102
+ end
103
+ end
104
+ end
105
+ end
@@ -2,23 +2,26 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "return_type_heuristic"
6
+
5
7
  module Rigor
6
8
  module Analysis
7
9
  module DependencySourceInference
8
10
  # Walks a resolved gem's `roots:` and collects the
9
- # `(class_name, method_name) → :instance | :singleton`
10
- # method catalog. The walker is the source of facts the
11
- # dispatcher tier (slice 2b-ii) consults to recognise a
11
+ # `(class_name, method_name) → CatalogEntry(kind,
12
+ # return_type)` method catalog. The walker is the source
13
+ # of facts the dispatcher tier consults to recognise a
12
14
  # method as defined by an opt-in gem and contribute a
13
- # `Type::Dynamic` return at the call site.
15
+ # `Type::Dynamic`-wrapped return at the call site.
14
16
  #
15
- # Slice 2b-i intentionally collects only the catalog, not
16
- # the inferred return type. The dispatcher tier returns
17
- # `Dynamic[top]` on a hit until slice 2b-ii wires return-
18
- # type inference; the visible payoff today is removing the
19
- # `call.undefined-method` diagnostic for opt-in gem methods
20
- # at receivers Rigor knows by `Nominal[T]` (typically
21
- # because the user authored an RBS skeleton).
17
+ # The dispatcher tier wraps every walker-contributed return
18
+ # in `Dynamic[T]` per ADR-10's gem-boundary contract. When
19
+ # the heuristic ({ReturnTypeHeuristic}) recognises the
20
+ # method body's tail expression, the dispatcher uses the
21
+ # heuristic's static facet; otherwise it falls back to
22
+ # `Dynamic[top]` (the pre-heuristic behaviour). The
23
+ # heuristic is intentionally narrow only literal-tail
24
+ # method bodies fold; everything else degrades silently.
22
25
  #
23
26
  # Hard exclusions are NOT user-configurable, per ADR-10
24
27
  # § "Hard exclusions": top-level `spec/`, `test/`, `bin/`,
@@ -45,6 +48,19 @@ module Rigor
45
48
  def truncated? = truncated
46
49
  end
47
50
 
51
+ # Per-method catalog entry. `kind` is `:instance` or
52
+ # `:singleton`; `return_type` is the
53
+ # {ReturnTypeHeuristic}-extracted static facet (a
54
+ # `Rigor::Type::*`) or `nil` when the heuristic declined.
55
+ # The dispatcher wraps a non-nil `return_type` in
56
+ # `Dynamic[T]`; a `nil` `return_type` falls back to
57
+ # `Dynamic[top]`.
58
+ class CatalogEntry < Data.define(:kind, :return_type)
59
+ def initialize(kind:, return_type: nil)
60
+ super
61
+ end
62
+ end
63
+
48
64
  # Sentinel for "no cap" — used by callers that don't
49
65
  # care about the budget (specs, tooling). Production
50
66
  # code MUST pass an integer.
@@ -175,7 +191,11 @@ module Rigor
175
191
 
176
192
  class_name = qualified_prefix.join("::")
177
193
  kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
178
- accumulator[[class_name, node.name]] ||= kind
194
+ key = [class_name, node.name]
195
+ return if accumulator.key?(key) # first walk wins
196
+
197
+ return_type = ReturnTypeHeuristic.extract(node)
198
+ accumulator[key] = CatalogEntry.new(kind: kind, return_type: return_type)
179
199
  end
180
200
 
181
201
  # Resolves a `Prism::ConstantPathNode` /
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # Frozen snapshot of the project-wide state {Runner} consumes
6
+ # before per-file analysis fires: the loaded plugin registry
7
+ # (with `#prepare` already invoked), the dependency-source
8
+ # index, the synthetic-method and project-patched indexes
9
+ # produced by the pre-pass scanners, and the diagnostics
10
+ # those passes emitted.
11
+ #
12
+ # Owners (`Rigor::LanguageServer::ProjectContext`, future
13
+ # editor / sig-gen integrations) build a ProjectScan once
14
+ # per project-state generation via
15
+ # `Runner#prepare_project_scan` and pass it to
16
+ # `Runner.new(prebuilt: ...)` so per-buffer publishes skip
17
+ # the scanner walks and `#prepare` re-runs. When watched
18
+ # project files change, the owner discards the ProjectScan
19
+ # and a fresh one builds on next read.
20
+ #
21
+ # Editor mode v1 contract reminder: scanners observe the
22
+ # bytes that were on disk at scan time, NOT the in-flight
23
+ # buffer. Edits to a file that itself declares synthetic
24
+ # methods (or `pre_eval:`-listed patches) are NOT visible
25
+ # until the owner invalidates the scan — typically via
26
+ # `workspace/didChangeWatchedFiles`. This is the same
27
+ # trade-off the LSP made when slice 7 cached only the
28
+ # `Environment`; extending the cache to the pre-pass
29
+ # outputs preserves the contract.
30
+ ProjectScan = Data.define(
31
+ :plugin_registry,
32
+ :dependency_source_index,
33
+ :synthetic_method_index,
34
+ :project_patched_methods,
35
+ :plugin_prepare_diagnostics,
36
+ :pre_eval_diagnostics
37
+ )
38
+ end
39
+ end