rigortype 0.1.1 → 0.1.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -0
  3. data/data/builtins/ruby_core/range.yml +6 -4
  4. data/data/builtins/ruby_core/string.yml +15 -10
  5. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
  6. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
  7. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
  8. data/lib/rigor/analysis/check_rules.rb +346 -18
  9. data/lib/rigor/analysis/dependency_source_inference/builder.rb +87 -0
  10. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +110 -0
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
  13. data/lib/rigor/analysis/dependency_source_inference.rb +37 -0
  14. data/lib/rigor/analysis/rule_catalog.rb +343 -0
  15. data/lib/rigor/analysis/runner.rb +96 -6
  16. data/lib/rigor/cache/descriptor.rb +58 -5
  17. data/lib/rigor/cli/diff_command.rb +169 -0
  18. data/lib/rigor/cli/explain_command.rb +129 -0
  19. data/lib/rigor/cli.rb +18 -1
  20. data/lib/rigor/configuration/dependencies.rb +235 -0
  21. data/lib/rigor/configuration/severity_profile.rb +18 -3
  22. data/lib/rigor/configuration.rb +53 -13
  23. data/lib/rigor/environment.rb +16 -4
  24. data/lib/rigor/flow_contribution/merger.rb +4 -0
  25. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
  26. data/lib/rigor/inference/method_dispatcher.rb +87 -0
  27. data/lib/rigor/inference/scope_indexer.rb +171 -2
  28. data/lib/rigor/inference/statement_evaluator.rb +65 -1
  29. data/lib/rigor/plugin/io_boundary.rb +92 -19
  30. data/lib/rigor/plugin/manifest.rb +26 -5
  31. data/lib/rigor/plugin/trust_policy.rb +30 -7
  32. data/lib/rigor/scope.rb +30 -5
  33. data/lib/rigor/version.rb +1 -1
  34. data/sig/rigor/environment.rbs +3 -2
  35. data/sig/rigor/scope.rbs +3 -0
  36. metadata +13 -1
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../cache/descriptor"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module DependencySourceInference
8
+ # Per-run collection of gem-source-inference state. Holds
9
+ # the resolved gems the walker MAY visit (slice 2b) plus
10
+ # the unresolvable entries the runner SHOULD surface as
11
+ # `dynamic.dependency-source.gem-not-found` diagnostics.
12
+ #
13
+ # Slice 2a lands the data structure only; the dispatcher
14
+ # tier consults {#contribution_for} but the lookup always
15
+ # answers `nil` until slice 2b populates the method table
16
+ # by walking the resolved gems' `roots:`.
17
+ class Index
18
+ attr_reader :resolved_gems, :unresolvable, :method_catalog, :budget_exceeded,
19
+ :class_to_gem, :budget_overrun_strategy
20
+
21
+ # @param method_catalog [Hash{[String, Symbol] => Symbol}]
22
+ # the flat `(class_name, method_name) → :instance | :singleton`
23
+ # table produced by {Walker.walk}, aggregated across
24
+ # every resolved gem in the run. The Index itself stays
25
+ # gem-agnostic — the per-gem attribution that slice 3's
26
+ # cache descriptor needs lives on `Resolved`, not here.
27
+ # @param budget_exceeded [Array<String>] gem names whose
28
+ # {Walker} run hit the per-gem catalog cap (slice 4).
29
+ # The Runner consumes this list to emit one
30
+ # `dynamic.dependency-source.budget-exceeded` warning
31
+ # per gem.
32
+ # @param class_to_gem [Hash<String, String>] reverse
33
+ # lookup `class_name → gem_name` (slice 5b). Built
34
+ # first-write-wins: when two opt-in gems re-open the
35
+ # same class, the first gem owns it. The dispatcher
36
+ # consults this map under the `:dependency_silence`
37
+ # budget overrun strategy so call sites on a
38
+ # budget-exceeded gem's classes degrade to
39
+ # `Dynamic[top]` instead of falling through to the
40
+ # user-class fallback.
41
+ def initialize( # rubocop:disable Metrics/ParameterLists
42
+ resolved_gems: [], unresolvable: [], method_catalog: {},
43
+ budget_exceeded: [], class_to_gem: {},
44
+ budget_overrun_strategy: :walker_cap
45
+ )
46
+ @resolved_gems = resolved_gems.freeze
47
+ @unresolvable = unresolvable.freeze
48
+ @method_catalog = method_catalog.freeze
49
+ @budget_exceeded = budget_exceeded.freeze
50
+ @class_to_gem = class_to_gem.freeze
51
+ @budget_overrun_strategy = budget_overrun_strategy
52
+ freeze
53
+ end
54
+
55
+ # @return [String, nil] the gem that owns `class_name`
56
+ # (first-write-wins); `nil` when the class isn't in
57
+ # any opt-in gem's catalog.
58
+ def gem_for(class_name)
59
+ @class_to_gem[class_name]
60
+ end
61
+
62
+ # Looks up the recorded method kind for a
63
+ # `(class_name, method_name)` pair. Returns `:instance`
64
+ # / `:singleton` when the walker observed a definition
65
+ # under one of the resolved gems' `roots:`, or `nil`
66
+ # otherwise. Slice 2b-ii enriches this with the inferred
67
+ # return type so the dispatcher tier can build a
68
+ # `Type::Dynamic` directly from the lookup result.
69
+ def contribution_for(class_name:, method_name:)
70
+ @method_catalog[[class_name, method_name]]
71
+ end
72
+
73
+ def empty?
74
+ @resolved_gems.empty?
75
+ end
76
+
77
+ # Builds a frozen `Cache::Descriptor` carrying one
78
+ # `DependencyEntry` row per resolved gem in this run.
79
+ # Cache producers that observe ADR-10 inference outputs
80
+ # compose this descriptor with their own (RBS, plugin,
81
+ # file-digest) descriptors so a `bundle update` on a
82
+ # listed gem invalidates exactly that gem's slice while
83
+ # leaving the rest of the cache hot.
84
+ #
85
+ # Unresolvable entries contribute nothing — there is no
86
+ # version to key on, and the runner already surfaces them
87
+ # as `dynamic.dependency-source.gem-not-found`
88
+ # diagnostics. Resolved-but-disabled entries are also
89
+ # absent: the {Builder} skips them before resolution, so
90
+ # they never reach the index.
91
+ def cache_descriptor
92
+ dependencies = @resolved_gems.map do |resolved|
93
+ Cache::Descriptor::DependencyEntry.new(
94
+ gem_name: resolved.gem_name,
95
+ gem_version: resolved.version,
96
+ mode: resolved.mode
97
+ )
98
+ end
99
+ Cache::Descriptor.new(dependencies: dependencies)
100
+ end
101
+ end
102
+
103
+ # Frozen empty index — the runner uses this when
104
+ # `Configuration#dependencies.source_inference` is empty
105
+ # so the dispatcher tier holds a stable, non-nil
106
+ # reference even on default configurations.
107
+ Index::EMPTY = Index.new.freeze
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module DependencySourceInference
8
+ # 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
12
+ # method as defined by an opt-in gem and contribute a
13
+ # `Type::Dynamic` return at the call site.
14
+ #
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).
22
+ #
23
+ # Hard exclusions are NOT user-configurable, per ADR-10
24
+ # § "Hard exclusions": top-level `spec/`, `test/`, `bin/`,
25
+ # plus any non-`.rb` source. C extensions fall out
26
+ # automatically because the walker only loads `.rb` files.
27
+ module Walker
28
+ # Top-level directories that MUST NOT participate in
29
+ # gem-source inference even when the user lists them
30
+ # under `roots:`. The check is case-insensitive against
31
+ # the first segment of `roots:`; nested `spec/` /
32
+ # `test/` directories deeper inside `lib/` are NOT
33
+ # filtered (a few gems legitimately ship `lib/.../spec/`).
34
+ HARD_EXCLUDED_ROOTS = %w[spec test bin].freeze
35
+
36
+ # Walker outcome wrapping the harvested method catalog
37
+ # plus a budget-exceeded flag. ADR-10 slice 4 introduces
38
+ # the cap; the Walker stops appending to the accumulator
39
+ # once `catalog.size` reaches `budget`, and `truncated?`
40
+ # reports whether the cap was reached. The Index records
41
+ # this per-gem so the Runner can surface a single
42
+ # `dynamic.dependency-source.budget-exceeded` warning
43
+ # naming the affected gem(s).
44
+ Outcome = Data.define(:catalog, :truncated) do
45
+ def truncated? = truncated
46
+ end
47
+
48
+ # Sentinel for "no cap" — used by callers that don't
49
+ # care about the budget (specs, tooling). Production
50
+ # code MUST pass an integer.
51
+ UNBOUNDED = Float::INFINITY
52
+
53
+ module_function
54
+
55
+ # @param gem_dir [String, Pathname] absolute path to the
56
+ # gem's installation directory.
57
+ # @param roots [Array<String>] subdirectory names within
58
+ # the gem to walk (defaults to `["lib"]` per
59
+ # `Configuration::Dependencies::Entry`).
60
+ # @param budget [Integer, Float] per-gem catalog cap
61
+ # (method-definition count). When unset, defaults to
62
+ # `UNBOUNDED` for backwards-compatible test paths.
63
+ # @return [Outcome] frozen wrapper carrying the catalog
64
+ # (`Hash{[class_name, method_name] => :instance |
65
+ # :singleton}`) and a `truncated?` flag set when the
66
+ # walker stopped harvesting because the budget was
67
+ # reached. Methods of identical name on the same class
68
+ # with different kinds (rare; private API mostly)
69
+ # carry the kind that wins the per-class first walk.
70
+ def walk(gem_dir:, roots:, budget: UNBOUNDED)
71
+ accumulator = {}
72
+ truncated = false
73
+ accepted_roots(roots).each do |root|
74
+ break if truncated
75
+
76
+ truncated = walk_root(File.join(gem_dir.to_s, root), accumulator, budget)
77
+ end
78
+ Outcome.new(catalog: accumulator.freeze, truncated: truncated)
79
+ end
80
+
81
+ # Drops hard-excluded entries before any filesystem
82
+ # walk happens. Reasoning: we never want a gem's
83
+ # `spec/` to participate even if the user requested
84
+ # it — the noise from RSpec-style globals plus the
85
+ # cost of walking test fixtures isn't worth the
86
+ # marginal coverage.
87
+ def accepted_roots(roots)
88
+ roots.reject { |root| HARD_EXCLUDED_ROOTS.include?(root.downcase) }
89
+ end
90
+
91
+ # Returns true when the budget tripped during this
92
+ # root's walk so the caller can stop iterating
93
+ # subsequent roots.
94
+ def walk_root(root_dir, accumulator, budget) # rubocop:disable Naming/PredicateMethod
95
+ return false unless File.directory?(root_dir)
96
+
97
+ Dir.glob(File.join(root_dir, "**", "*.rb")).each do |path|
98
+ harvest_file(path, accumulator, budget)
99
+ return true if accumulator.size >= budget
100
+ end
101
+ false
102
+ end
103
+
104
+ def harvest_file(path, accumulator, budget)
105
+ parse_result = Prism.parse_file(path)
106
+ return unless parse_result.errors.empty?
107
+
108
+ walk_node(parse_result.value, [], false, accumulator, budget)
109
+ rescue StandardError
110
+ # Gem source we can't parse / read silently degrades
111
+ # to "no contribution from this file". The user-facing
112
+ # diagnostic stream is reserved for the project source;
113
+ # opt-in gem source MUST NOT pollute it with parse
114
+ # errors the user cannot fix.
115
+ nil
116
+ end
117
+
118
+ # Walks a Prism subtree, accumulating method definitions
119
+ # under their qualified class name. Mirrors the shape of
120
+ # `Inference::ScopeIndexer#walk_methods` but stays
121
+ # decoupled from `Scope` because gem-source inference
122
+ # runs without a scope context.
123
+ def walk_node(node, qualified_prefix, in_singleton_class, accumulator, budget)
124
+ return unless node.is_a?(Prism::Node)
125
+ return if accumulator.size >= budget
126
+
127
+ case node
128
+ when Prism::ClassNode, Prism::ModuleNode
129
+ descend_class_or_module(node, qualified_prefix, in_singleton_class, accumulator, budget)
130
+ when Prism::SingletonClassNode
131
+ descend_singleton_class(node, qualified_prefix, accumulator, budget)
132
+ when Prism::DefNode
133
+ record_def_node(node, qualified_prefix, in_singleton_class, accumulator, budget)
134
+ else
135
+ walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
136
+ end
137
+ end
138
+
139
+ def walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
140
+ node.compact_child_nodes.each do |child|
141
+ break if accumulator.size >= budget
142
+
143
+ walk_node(child, qualified_prefix, in_singleton_class, accumulator, budget)
144
+ end
145
+ end
146
+
147
+ # `class Foo` / `module Bar`. The dynamic-prefix shape
148
+ # (`module ::Foo`-rooted variants whose left side is a
149
+ # runtime expression) is treated as opaque — we walk the
150
+ # children under the same prefix so any inner class
151
+ # definitions are still recorded under their own name.
152
+ def descend_class_or_module(node, qualified_prefix, in_singleton_class, accumulator, budget)
153
+ name = qualified_name_for(node.constant_path)
154
+ if name && node.body
155
+ walk_node(node.body, qualified_prefix + [name], in_singleton_class, accumulator, budget)
156
+ else
157
+ walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
158
+ end
159
+ end
160
+
161
+ # `class << self` only — `class << expr` for any other
162
+ # `expr` is treated as opaque so we don't accidentally
163
+ # record per-instance singleton methods under the
164
+ # surrounding class.
165
+ def descend_singleton_class(node, qualified_prefix, accumulator, budget)
166
+ if node.expression.is_a?(Prism::SelfNode) && node.body
167
+ walk_node(node.body, qualified_prefix, true, accumulator, budget)
168
+ else
169
+ walk_children(node, qualified_prefix, false, accumulator, budget)
170
+ end
171
+ end
172
+
173
+ def record_def_node(node, qualified_prefix, in_singleton_class, accumulator, _budget)
174
+ return if qualified_prefix.empty?
175
+
176
+ class_name = qualified_prefix.join("::")
177
+ kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
178
+ accumulator[[class_name, node.name]] ||= kind
179
+ end
180
+
181
+ # Resolves a `Prism::ConstantPathNode` /
182
+ # `Prism::ConstantReadNode` chain to its dot-separated
183
+ # name (e.g. `"Foo::Bar"`). Returns nil for the rare
184
+ # dynamic-prefix shape (`module ::Foo`-rooted variants
185
+ # whose left side is a runtime expression) so the
186
+ # walker treats those as opaque rather than guessing.
187
+ def qualified_name_for(node)
188
+ case node
189
+ when Prism::ConstantReadNode then node.name.to_s
190
+ when Prism::ConstantPathNode
191
+ parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
192
+ return nil if !node.parent.nil? && parent.nil?
193
+
194
+ parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dependency_source_inference/gem_resolver"
4
+ require_relative "dependency_source_inference/index"
5
+ require_relative "dependency_source_inference/walker"
6
+ require_relative "dependency_source_inference/builder"
7
+
8
+ module Rigor
9
+ module Analysis
10
+ # Implementation of [ADR-10 — Opt-in dependency-source
11
+ # inference](../../../docs/adr/10-dependency-source-inference.md).
12
+ #
13
+ # The namespace coordinates three components:
14
+ #
15
+ # - {GemResolver} maps a
16
+ # `Configuration::Dependencies::Entry` to either a frozen
17
+ # `Resolved(gem_name, version, gem_dir, mode, roots)` or an
18
+ # `Unresolvable(gem_name, reason)` value.
19
+ # - {Builder.build} folds a `Configuration::Dependencies`
20
+ # into a frozen {Index} carrying the partitioned outcomes.
21
+ # - {Index} holds the per-run state the dispatcher tier
22
+ # consults via `#contribution_for`. Slice 2a ships the
23
+ # stub returning `nil`; slice 2b populates the method
24
+ # table by walking each resolved gem's `roots:`.
25
+ #
26
+ # Per the ADR's "Implementation slicing" section, slice 2 is
27
+ # split internally:
28
+ #
29
+ # - Slice 2a (this commit): gem resolution, index plumbing,
30
+ # `Analysis::Runner` wiring, `dynamic.dependency-source.gem-not-found`
31
+ # diagnostic for unresolvable entries.
32
+ # - Slice 2b (next commit): walker, dispatcher tier
33
+ # integration, `Type::Dynamic`-wrapped returns.
34
+ module DependencySourceInference
35
+ end
36
+ end
37
+ end