rigortype 0.1.2 → 0.1.4

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +135 -31
  3. data/lib/rigor/analysis/check_rules.rb +10 -18
  4. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
  5. data/lib/rigor/analysis/dependency_source_inference/builder.rb +113 -0
  6. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
  7. data/lib/rigor/analysis/dependency_source_inference/index.rb +139 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
  9. data/lib/rigor/analysis/dependency_source_inference.rb +38 -0
  10. data/lib/rigor/analysis/diagnostic.rb +0 -2
  11. data/lib/rigor/analysis/fact_store.rb +11 -3
  12. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  13. data/lib/rigor/analysis/runner.rb +206 -6
  14. data/lib/rigor/builtins/imported_refinements.rb +360 -55
  15. data/lib/rigor/cache/descriptor.rb +59 -6
  16. data/lib/rigor/cache/store.rb +1 -1
  17. data/lib/rigor/cli/diff_command.rb +1 -1
  18. data/lib/rigor/cli/sig_gen_command.rb +173 -0
  19. data/lib/rigor/cli/type_of_command.rb +1 -1
  20. data/lib/rigor/cli/type_scan_renderer.rb +1 -1
  21. data/lib/rigor/cli/type_scan_report.rb +2 -2
  22. data/lib/rigor/cli.rb +9 -1
  23. data/lib/rigor/configuration/dependencies.rb +235 -0
  24. data/lib/rigor/configuration.rb +45 -11
  25. data/lib/rigor/environment.rb +47 -4
  26. data/lib/rigor/flow_contribution/conflict.rb +2 -2
  27. data/lib/rigor/flow_contribution/element.rb +1 -1
  28. data/lib/rigor/flow_contribution/fact.rb +1 -1
  29. data/lib/rigor/flow_contribution/merge_result.rb +1 -1
  30. data/lib/rigor/flow_contribution/merger.rb +7 -3
  31. data/lib/rigor/flow_contribution.rb +2 -2
  32. data/lib/rigor/inference/block_parameter_binder.rb +0 -2
  33. data/lib/rigor/inference/coverage_scanner.rb +1 -1
  34. data/lib/rigor/inference/expression_typer.rb +67 -11
  35. data/lib/rigor/inference/fallback.rb +1 -1
  36. data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
  37. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +0 -12
  38. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
  39. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
  40. data/lib/rigor/inference/method_dispatcher/method_folding.rb +118 -0
  41. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -11
  42. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
  43. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +0 -4
  44. data/lib/rigor/inference/method_dispatcher.rb +233 -2
  45. data/lib/rigor/inference/method_parameter_binder.rb +1 -3
  46. data/lib/rigor/inference/narrowing.rb +2 -4
  47. data/lib/rigor/inference/rbs_type_translator.rb +0 -2
  48. data/lib/rigor/inference/scope_indexer.rb +14 -9
  49. data/lib/rigor/inference/statement_evaluator.rb +70 -6
  50. data/lib/rigor/plugin/io_boundary.rb +0 -2
  51. data/lib/rigor/plugin/loader.rb +2 -2
  52. data/lib/rigor/plugin/manifest.rb +49 -7
  53. data/lib/rigor/plugin/registry.rb +11 -0
  54. data/lib/rigor/plugin/services.rb +1 -1
  55. data/lib/rigor/plugin/type_node_resolver.rb +52 -0
  56. data/lib/rigor/plugin.rb +1 -0
  57. data/lib/rigor/rbs_extended/reporter.rb +91 -0
  58. data/lib/rigor/rbs_extended.rb +131 -32
  59. data/lib/rigor/scope.rb +25 -8
  60. data/lib/rigor/sig_gen/classification.rb +36 -0
  61. data/lib/rigor/sig_gen/generator.rb +1048 -0
  62. data/lib/rigor/sig_gen/layout_index.rb +108 -0
  63. data/lib/rigor/sig_gen/method_candidate.rb +62 -0
  64. data/lib/rigor/sig_gen/observation_collector.rb +391 -0
  65. data/lib/rigor/sig_gen/observed_call.rb +62 -0
  66. data/lib/rigor/sig_gen/path_mapper.rb +116 -0
  67. data/lib/rigor/sig_gen/renderer.rb +157 -0
  68. data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
  69. data/lib/rigor/sig_gen/write_result.rb +48 -0
  70. data/lib/rigor/sig_gen/writer.rb +530 -0
  71. data/lib/rigor/sig_gen.rb +25 -0
  72. data/lib/rigor/type/bound_method.rb +79 -0
  73. data/lib/rigor/type/combinator.rb +195 -2
  74. data/lib/rigor/type/constant.rb +13 -0
  75. data/lib/rigor/type/hash_shape.rb +0 -2
  76. data/lib/rigor/type/union.rb +20 -1
  77. data/lib/rigor/type.rb +1 -0
  78. data/lib/rigor/type_node/generic.rb +62 -0
  79. data/lib/rigor/type_node/identifier.rb +30 -0
  80. data/lib/rigor/type_node/indexed_access.rb +41 -0
  81. data/lib/rigor/type_node/integer_literal.rb +29 -0
  82. data/lib/rigor/type_node/name_scope.rb +52 -0
  83. data/lib/rigor/type_node/resolver_chain.rb +56 -0
  84. data/lib/rigor/type_node/string_literal.rb +29 -0
  85. data/lib/rigor/type_node/symbol_literal.rb +28 -0
  86. data/lib/rigor/type_node/union.rb +42 -0
  87. data/lib/rigor/type_node.rb +29 -0
  88. data/lib/rigor/version.rb +1 -1
  89. data/lib/rigor.rb +2 -0
  90. data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
  91. data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
  92. data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
  93. data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
  94. data/sig/rigor/cli/diff_command.rbs +4 -0
  95. data/sig/rigor/cli/explain_command.rbs +4 -0
  96. data/sig/rigor/cli/sig_gen_command.rbs +4 -0
  97. data/sig/rigor/cli/type_scan_command.rbs +3 -0
  98. data/sig/rigor/environment.rbs +6 -2
  99. data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
  100. data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
  101. data/sig/rigor/inference/builtins.rbs +2 -0
  102. data/sig/rigor/plugin/access_denied_error.rbs +3 -0
  103. data/sig/rigor/plugin/base.rbs +6 -0
  104. data/sig/rigor/plugin/fact_store.rbs +11 -0
  105. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  106. data/sig/rigor/plugin/load_error.rbs +6 -0
  107. data/sig/rigor/plugin/loader.rbs +20 -0
  108. data/sig/rigor/plugin/manifest.rbs +9 -0
  109. data/sig/rigor/plugin/registry.rbs +3 -0
  110. data/sig/rigor/plugin/services.rbs +3 -0
  111. data/sig/rigor/plugin/trust_policy.rbs +4 -0
  112. data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
  113. data/sig/rigor/plugin.rbs +8 -0
  114. data/sig/rigor/scope.rbs +4 -2
  115. data/sig/rigor/type.rbs +28 -6
  116. metadata +58 -1
@@ -0,0 +1,139 @@
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, :gem_modes
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
+ # @param gem_modes [Hash<String, Symbol>] per-gem mode
42
+ # table (`gem_name → :disabled | :when_missing |
43
+ # :full`). ADR-10 slice 5c consults this through
44
+ # {#mode_for} to identify call sites where gem-source
45
+ # and RBS both contribute under `mode: :full`. The map
46
+ # is keyed on `gem_name` (not class) because re-opened
47
+ # classes belong to the first gem they appeared in
48
+ # per `class_to_gem`; `mode_for(class_name)` chains
49
+ # the two lookups.
50
+ def initialize(
51
+ resolved_gems: [], unresolvable: [], method_catalog: {},
52
+ budget_exceeded: [], class_to_gem: {},
53
+ budget_overrun_strategy: :walker_cap, gem_modes: {}
54
+ )
55
+ @resolved_gems = resolved_gems.freeze
56
+ @unresolvable = unresolvable.freeze
57
+ @method_catalog = method_catalog.freeze
58
+ @budget_exceeded = budget_exceeded.freeze
59
+ @class_to_gem = class_to_gem.freeze
60
+ @budget_overrun_strategy = budget_overrun_strategy
61
+ @gem_modes = gem_modes.freeze
62
+ freeze
63
+ end
64
+
65
+ # @return [String, nil] the gem that owns `class_name`
66
+ # (first-write-wins); `nil` when the class isn't in
67
+ # any opt-in gem's catalog.
68
+ def gem_for(class_name)
69
+ @class_to_gem[class_name]
70
+ end
71
+
72
+ # ADR-10 slice 5c — per-class mode lookup. Chains
73
+ # `class_to_gem` + `gem_modes`; returns `nil` when the
74
+ # class isn't owned by any opt-in gem in this run.
75
+ def mode_for(class_name)
76
+ gem_name = @class_to_gem[class_name]
77
+ return nil if gem_name.nil?
78
+
79
+ @gem_modes[gem_name]
80
+ end
81
+
82
+ # ADR-10 slice 5c — true when the receiver class belongs
83
+ # to a gem the user opted into `mode: :full` for. The
84
+ # dispatcher consults this AFTER an authoritative-source
85
+ # (RBS / plugin) dispatch resolves so it can record the
86
+ # boundary-crossing for audit.
87
+ def full_mode?(class_name)
88
+ mode_for(class_name) == :full
89
+ end
90
+
91
+ # Looks up the recorded method kind for a
92
+ # `(class_name, method_name)` pair. Returns `:instance`
93
+ # / `:singleton` when the walker observed a definition
94
+ # under one of the resolved gems' `roots:`, or `nil`
95
+ # otherwise. Slice 2b-ii enriches this with the inferred
96
+ # return type so the dispatcher tier can build a
97
+ # `Type::Dynamic` directly from the lookup result.
98
+ def contribution_for(class_name:, method_name:)
99
+ @method_catalog[[class_name, method_name]]
100
+ end
101
+
102
+ def empty?
103
+ @resolved_gems.empty?
104
+ end
105
+
106
+ # Builds a frozen `Cache::Descriptor` carrying one
107
+ # `DependencyEntry` row per resolved gem in this run.
108
+ # Cache producers that observe ADR-10 inference outputs
109
+ # compose this descriptor with their own (RBS, plugin,
110
+ # file-digest) descriptors so a `bundle update` on a
111
+ # listed gem invalidates exactly that gem's slice while
112
+ # leaving the rest of the cache hot.
113
+ #
114
+ # Unresolvable entries contribute nothing — there is no
115
+ # version to key on, and the runner already surfaces them
116
+ # as `dynamic.dependency-source.gem-not-found`
117
+ # diagnostics. Resolved-but-disabled entries are also
118
+ # absent: the {Builder} skips them before resolution, so
119
+ # they never reach the index.
120
+ def cache_descriptor
121
+ dependencies = @resolved_gems.map do |resolved|
122
+ Cache::Descriptor::DependencyEntry.new(
123
+ gem_name: resolved.gem_name,
124
+ gem_version: resolved.version,
125
+ mode: resolved.mode
126
+ )
127
+ end
128
+ Cache::Descriptor.new(dependencies: dependencies)
129
+ end
130
+ end
131
+
132
+ # Frozen empty index — the runner uses this when
133
+ # `Configuration#dependencies.source_inference` is empty
134
+ # so the dispatcher tier holds a stable, non-nil
135
+ # reference even on default configurations.
136
+ Index::EMPTY = Index.new.freeze
137
+ end
138
+ end
139
+ 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
+ class Outcome < Data.define(:catalog, :truncated)
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,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dependency_source_inference/boundary_cross_reporter"
4
+ require_relative "dependency_source_inference/gem_resolver"
5
+ require_relative "dependency_source_inference/index"
6
+ require_relative "dependency_source_inference/walker"
7
+ require_relative "dependency_source_inference/builder"
8
+
9
+ module Rigor
10
+ module Analysis
11
+ # Implementation of [ADR-10 — Opt-in dependency-source
12
+ # inference](../../../docs/adr/10-dependency-source-inference.md).
13
+ #
14
+ # The namespace coordinates three components:
15
+ #
16
+ # - {GemResolver} maps a
17
+ # `Configuration::Dependencies::Entry` to either a frozen
18
+ # `Resolved(gem_name, version, gem_dir, mode, roots)` or an
19
+ # `Unresolvable(gem_name, reason)` value.
20
+ # - {Builder.build} folds a `Configuration::Dependencies`
21
+ # into a frozen {Index} carrying the partitioned outcomes.
22
+ # - {Index} holds the per-run state the dispatcher tier
23
+ # consults via `#contribution_for`. Slice 2a ships the
24
+ # stub returning `nil`; slice 2b populates the method
25
+ # table by walking each resolved gem's `roots:`.
26
+ #
27
+ # Per the ADR's "Implementation slicing" section, slice 2 is
28
+ # split internally:
29
+ #
30
+ # - Slice 2a (this commit): gem resolution, index plumbing,
31
+ # `Analysis::Runner` wiring, `dynamic.dependency-source.gem-not-found`
32
+ # diagnostic for unresolvable entries.
33
+ # - Slice 2b (next commit): walker, dispatcher tier
34
+ # integration, `Type::Dynamic`-wrapped returns.
35
+ module DependencySourceInference
36
+ end
37
+ end
38
+ end
@@ -24,10 +24,8 @@ module Rigor
24
24
  # ADR-2 § "Plugin Diagnostic Provenance") let consumers
25
25
  # distinguish where a diagnostic originated without committing
26
26
  # to the plugin API itself.
27
- # rubocop:disable Metrics/ParameterLists
28
27
  def initialize(path:, line:, column:, message:, severity: :error, rule: nil,
29
28
  source_family: DEFAULT_SOURCE_FAMILY)
30
- # rubocop:enable Metrics/ParameterLists
31
29
  @path = path
32
30
  @line = line
33
31
  @column = column
@@ -18,7 +18,7 @@ module Rigor
18
18
  relational
19
19
  ].freeze
20
20
 
21
- Target = Data.define(:kind, :name) do
21
+ class Target < Data.define(:kind, :name)
22
22
  def self.local(name)
23
23
  new(kind: :local, name: name.to_sym)
24
24
  end
@@ -28,7 +28,7 @@ module Rigor
28
28
  end
29
29
  end
30
30
 
31
- Fact = Data.define(:bucket, :target, :predicate, :payload, :polarity, :stability) do
31
+ class Fact < Data.define(:bucket, :target, :predicate, :payload, :polarity, :stability)
32
32
  def initialize(bucket:, target:, predicate:, payload: nil, polarity: :positive, stability: :local_binding)
33
33
  bucket = bucket.to_sym
34
34
  raise ArgumentError, "unknown fact bucket #{bucket.inspect}" unless BUCKETS.include?(bucket)
@@ -125,8 +125,16 @@ module Rigor
125
125
  unique.freeze
126
126
  end
127
127
 
128
+ # `fact.target` is `Target | Array[Target]` per the carrier
129
+ # contract. Branching with an early return on the `Array`
130
+ # arm lets type narrowing collapse the post-return value to
131
+ # the bare `Target` case, so the wrapped tuple is `[Target]`
132
+ # and the union of return paths is exactly `Array[Target]`.
128
133
  def fact_targets(fact)
129
- Array(fact.target)
134
+ target = fact.target
135
+ return target if target.is_a?(Array)
136
+
137
+ [target]
130
138
  end
131
139
  end
132
140
  end
@@ -31,8 +31,8 @@ module Rigor
31
31
  # from `Configuration::SeverityProfile::PROFILES`.
32
32
  # - `since` — first version the rule shipped in.
33
33
  module RuleCatalog # rubocop:disable Metrics/ModuleLength
34
- Entry = Data.define(:id, :summary, :fires_when, :does_not_fire_when,
35
- :suppression, :severity_authored, :severity_by_profile, :since) do
34
+ class Entry < Data.define(:id, :summary, :fires_when, :does_not_fire_when,
35
+ :suppression, :severity_authored, :severity_by_profile, :since)
36
36
  def aliases
37
37
  CheckRules::LEGACY_RULE_ALIASES.select { |_legacy, canonical| canonical == id }.keys
38
38
  end