rigortype 0.4.2 → 0.4.3

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +12 -5
  4. data/docs/manual/02-cli-reference.md +19 -1
  5. data/docs/manual/03-configuration.md +6 -4
  6. data/docs/manual/05-inspecting-types.md +50 -0
  7. data/docs/manual/12-caching.md +18 -1
  8. data/docs/manual/14-rails-quickstart.md +2 -2
  9. data/docs/manual/18-removing-dead-code.md +16 -9
  10. data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
  11. data/lib/rigor/analysis/check_rules.rb +23 -22
  12. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  13. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  14. data/lib/rigor/analysis/incremental_session.rb +89 -22
  15. data/lib/rigor/analysis/path_expansion.rb +62 -6
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  17. data/lib/rigor/analysis/reachability/graph.rb +119 -15
  18. data/lib/rigor/analysis/reachability/scan.rb +332 -173
  19. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  20. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  21. data/lib/rigor/analysis/run_stats.rb +28 -6
  22. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
  23. data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
  24. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  25. data/lib/rigor/analysis/runner.rb +29 -11
  26. data/lib/rigor/analysis/worker_session.rb +1 -1
  27. data/lib/rigor/cache/engine_source.rb +21 -7
  28. data/lib/rigor/cache/incremental_snapshot.rb +36 -8
  29. data/lib/rigor/cli/check_command.rb +138 -39
  30. data/lib/rigor/cli/docs_command.rb +5 -4
  31. data/lib/rigor/cli/lens_command.rb +144 -0
  32. data/lib/rigor/cli/lsp_command.rb +5 -2
  33. data/lib/rigor/cli/skill_command.rb +3 -3
  34. data/lib/rigor/cli/skill_describe.rb +1 -1
  35. data/lib/rigor/cli/type_of_command.rb +2 -1
  36. data/lib/rigor/cli/unused_command.rb +157 -24
  37. data/lib/rigor/cli.rb +30 -4
  38. data/lib/rigor/configuration.rb +15 -0
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  40. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  41. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  42. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  43. data/lib/rigor/environment/rbs_loader.rb +33 -0
  44. data/lib/rigor/environment/required_features.rb +4 -2
  45. data/lib/rigor/environment.rb +12 -5
  46. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  47. data/lib/rigor/inference/captured_locals.rb +43 -3
  48. data/lib/rigor/inference/expression_typer.rb +58 -25
  49. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  50. data/lib/rigor/inference/match_rebinding.rb +1 -1
  51. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  52. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  53. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  54. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  55. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  56. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  57. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  58. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  59. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  60. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  61. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  62. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  63. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  64. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  65. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  66. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  67. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  68. data/lib/rigor/inference/scope_indexer.rb +140 -5
  69. data/lib/rigor/inference/statement_evaluator.rb +223 -31
  70. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  71. data/lib/rigor/language_server/project_context.rb +56 -5
  72. data/lib/rigor/lens/anchor.rb +115 -0
  73. data/lib/rigor/lens/contract_reader.rb +118 -0
  74. data/lib/rigor/lens/document.rb +113 -0
  75. data/lib/rigor/lens/file_reader.rb +433 -0
  76. data/lib/rigor/lens/members.rb +153 -0
  77. data/lib/rigor/lens/parameters.rb +91 -0
  78. data/lib/rigor/lens/plugins.rb +40 -0
  79. data/lib/rigor/lens/run.rb +280 -0
  80. data/lib/rigor/lens/slot.rb +79 -0
  81. data/lib/rigor/lens.rb +40 -0
  82. data/lib/rigor/project_environment.rb +20 -1
  83. data/lib/rigor/protection/mutation_cache.rb +2 -1
  84. data/lib/rigor/scope.rb +22 -11
  85. data/lib/rigor/sig_gen/generator.rb +45 -5
  86. data/lib/rigor/version.rb +1 -1
  87. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  88. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  89. data/sig/rigor/scope.rbs +3 -0
  90. data/sig/rigor.rbs +6 -3
  91. data/skills/rigor-project-init/references/02-configure.md +2 -2
  92. metadata +18 -1
@@ -5,6 +5,121 @@ require_relative "scan"
5
5
  module Rigor
6
6
  module Analysis
7
7
  module Reachability
8
+ # ADR-102 WD4 — how {Graph} reads a `constantize` / `const_get` site: a literal name becomes a reference
9
+ # looked up where Ruby looks it up, and an interpolated one on a known receiver is anchored there.
10
+ module DynamicLookup
11
+ private
12
+
13
+ # A literal-argument `"Foo::Bar".constantize` names its constant exactly, so it is a REFERENCE, not an
14
+ # unknown. Keeping this distinct from {Graph#tainted} is what stops the tier being a blanket namespace
15
+ # poison — Rigor knows the argument's shape, and a type-free indexer does not.
16
+ #
17
+ # A literal `const_get` on a known receiver is looked up where Ruby looks it up (#1761): the receiver,
18
+ # then its ancestors unless the call passes `inherit = false`, then the top level unless it does. A
19
+ # receiver holding the name is the answer — resolving it anywhere else too would make a dead same-named
20
+ # constant reachable; past the receiver see {#literal_targets}. A receiver that does not resolve as a
21
+ # whole is unknown — `Outer::Missing` peeled to `Outer` may alias anything, and `const_get` never
22
+ # searches `Outer` — so it falls back to the top level as an unknown receiver does.
23
+ #
24
+ # The reference carries the referring file's role (WD8): a spec's `const_get("Foo")` keeps `Foo`
25
+ # test-reachable, as its written `Foo` would, rather than making it live in production.
26
+ def literal_dynamic_references(dynamic_uses)
27
+ dynamic_uses.flat_map do |use|
28
+ next [] if use.name.nil?
29
+
30
+ name = use.name.delete_prefix("::")
31
+ targets = use.within ? literal_targets(use, name) : [nil]
32
+ targets.map do |target|
33
+ Scan::Reference.new(as_written: target || name, nesting: [].freeze, from: nil,
34
+ role: use.role, path: use.path, line: use.line,
35
+ rooted: !target.nil? || use.name.start_with?("::"))
36
+ end
37
+ end
38
+ end
39
+
40
+ # The declarations a literal `name` can name when looked up from `use.within`, a member's owner peeled
41
+ # as {#resolve} peels it; nil stands for the top level. The receiver declaring it is the answer. Past
42
+ # the receiver the walk only approximates Ruby: an ancestor named as written may resolve elsewhere
43
+ # (`class Api::Thing < Base` inherits `::Base`, not `Api::Base`), so every scope an ancestor could be
44
+ # that declares the name is kept, and the top level with them. Keeping a dead constant reachable is
45
+ # the cheaper mistake than listing a live one. A receiver that does not resolve is the top level; with
46
+ # `inherit = false` a resolved receiver that does not declare the name is nothing (Ruby raises
47
+ # `NameError` there).
48
+ def literal_targets(use, name)
49
+ receiver = use.receiver { |ref| resolve_ref(ref) }
50
+ return [nil] if receiver.nil?
51
+
52
+ local = @shadows[use.path]
53
+ own = declared_member(receiver, name, local)
54
+ return [own] if own
55
+ return [] unless use.inherit
56
+
57
+ plausible_ancestors(receiver).filter_map { |scope| declared_member(scope, name, local) }.uniq + [nil]
58
+ end
59
+
60
+ # `scope::name`, or the longest declared prefix of it, when one is declared.
61
+ def declared_member(scope, name, local)
62
+ segments = name.split("::")
63
+ segments.length.downto(1) do |count|
64
+ candidate = "#{scope}::#{segments.first(count).join('::')}"
65
+ return candidate if declared?(candidate, nil, local)
66
+ end
67
+ nil
68
+ end
69
+
70
+ # Every declaration an ancestor of `fqn` could be: each as-written superclass or mixin resolved at
71
+ # every enclosing scope that declares it, not only the innermost, and transitively.
72
+ def plausible_ancestors(fqn)
73
+ (@plausible_ancestors ||= {})[fqn] ||= plausible_ancestor_walk(fqn)
74
+ end
75
+
76
+ def plausible_ancestor_walk(fqn)
77
+ seen = Set[fqn]
78
+ queue = [fqn]
79
+ out = []
80
+ until queue.empty?
81
+ current = queue.shift
82
+ @by_fqn.fetch(current, []).each do |decl|
83
+ (decl.includes + [decl.superclass]).compact.each do |raw|
84
+ plausible_resolutions(current, raw).each do |resolved|
85
+ next if seen.include?(resolved)
86
+
87
+ seen << resolved
88
+ out << resolved
89
+ queue << resolved
90
+ end
91
+ end
92
+ end
93
+ end
94
+ out.freeze
95
+ end
96
+
97
+ def plausible_resolutions(subclass_fqn, raw)
98
+ segments = subclass_fqn.split("::")
99
+ (segments.length - 1).downto(0).filter_map do |i|
100
+ candidate = (segments[0, i] + [raw]).join("::")
101
+ candidate if candidate != subclass_fqn && @by_fqn.key?(candidate)
102
+ end
103
+ end
104
+
105
+ # The namespaces a `const_get` on `anchors` searches before the top level: the receiver, then, with the
106
+ # default `inherit = true`, every scope its superclass chain and mixins could be (#1761) — a taint is
107
+ # safe to over-reach.
108
+ def receiver_scopes(use, anchors)
109
+ return anchors unless use.inherit
110
+
111
+ anchors.flat_map { |anchor| [anchor, *plausible_ancestors(anchor)] }.uniq
112
+ end
113
+
114
+ # An interpolated site on a known receiver, with the receiver resolved into absolute `prefix`es.
115
+ def anchored(use)
116
+ return [use] if use.within.nil? || use.prefix.nil?
117
+
118
+ anchors = use.anchors { |ref| resolve_ref(ref) }
119
+ receiver_scopes(use, anchors).map { |scope| use.with(prefix: "#{scope}::#{use.prefix}", within: nil) }
120
+ end
121
+ end
122
+
8
123
  # ADR-102 — the cross-file half: resolves every as-written reference to a declaration, then marks from the
9
124
  # root set. Pure data; no engine coupling, so `rigor check`'s diagnostic stream is untouched by
10
125
  # construction rather than by a gate (WD1).
@@ -15,6 +130,8 @@ module Rigor
15
130
  # believe in. The two walks are separate implementations (this one is name-level and needs no types), so
16
131
  # `spec/rigor/analysis/reachability/graph_spec.rb` pins them to the same answers on shared fixtures.
17
132
  class Graph
133
+ include DynamicLookup
134
+
18
135
  Candidate = Data.define(:fqn, :path, :line)
19
136
 
20
137
  # `test_only` is its own list, not a flag on `candidates`: a candidate is by definition unreachable, so
@@ -40,7 +157,6 @@ module Rigor
40
157
  def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], shadows: [],
41
158
  foreign: ->(_fqn) { false })
42
159
  @declarations = declarations
43
- @references = references + literal_dynamic_references(dynamic_uses)
44
160
  @root_fqns = root_fqns.to_set
45
161
  @foreign = foreign
46
162
  @by_fqn = declarations.group_by(&:fqn)
@@ -48,8 +164,9 @@ module Rigor
48
164
  @shadows.default = Set.new.freeze
49
165
  @owned = @by_fqn.keys.reject { |fqn| @foreign.call(fqn) }.to_set
50
166
  @ancestors = {}
167
+ @references = references + literal_dynamic_references(dynamic_uses)
51
168
  # `Foo.const_get("V#{k}")` bounds its reach by `Foo`, a reference like any other (#1734).
52
- @dynamic_uses = dynamic_uses.flat_map { |use| use.anchored { |ref| resolve_ref(ref) } }
169
+ @dynamic_uses = dynamic_uses.flat_map { |use| anchored(use) }
53
170
  end
54
171
 
55
172
  def report
@@ -87,19 +204,6 @@ module Rigor
87
204
  namespaces: namespaces.size, roots: production_seeds.size, edges: edges.size)
88
205
  end
89
206
 
90
- # A literal-argument `"Foo::Bar".constantize` names its constant exactly, so it is a REFERENCE, not an
91
- # unknown. Keeping this distinct from the taint below is what stops the tier being a blanket namespace
92
- # poison — Rigor knows the argument's shape, and a type-free indexer does not.
93
- def literal_dynamic_references(dynamic_uses)
94
- dynamic_uses.filter_map do |use|
95
- next if use.name.nil?
96
-
97
- Scan::Reference.new(as_written: use.name.sub(/\A::/, ""), nesting: [].freeze, from: nil,
98
- role: :production, path: use.path, line: use.line,
99
- rooted: use.name.start_with?("::"))
100
- end
101
- end
102
-
103
207
  # `{fqn => reason}` for every declaration in `fqns` a dynamic site could still be naming. A site with a
104
208
  # literal prefix taints that namespace and everything under it; a site with no prefix at all cannot be
105
209
  # bounded, so it taints nothing rather than everything — poisoning the whole project would empty the