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,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check_rules"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ # Single-source-of-truth metadata table for every CheckRule
8
+ # the analyzer ships. Consumed by `rigor explain <rule>` so
9
+ # users can read the same information the docs site eventually
10
+ # publishes without leaving the terminal.
11
+ #
12
+ # Each entry carries:
13
+ #
14
+ # - `id` — canonical rule id (`call.undefined-method`).
15
+ # - `summary` — single-line headline (≤ 80 chars).
16
+ # - `fires_when` — bullet-shaped list of conditions that
17
+ # trigger the rule, in the order a reader can scan
18
+ # top-to-bottom.
19
+ # - `does_not_fire_when` — explicit list of cases the rule
20
+ # intentionally skips. Useful for "why am I NOT seeing
21
+ # this diagnostic?" questions.
22
+ # - `suppression` — short note on how to suppress (in-source
23
+ # `# rigor:disable` and the v0.1.2 file-scope variant
24
+ # `# rigor:disable-file`, plus `.rigor.yml` `disable:`,
25
+ # apply to every rule, so the note covers any rule-specific
26
+ # nuance — e.g. unreachable-branch lives on the dead-branch
27
+ # line, not the predicate line).
28
+ # - `severity_authored` — Symbol the rule emits with.
29
+ # - `severity_by_profile` — Hash of `:lenient` / `:balanced`
30
+ # / `:strict` to the configured severity per profile, taken
31
+ # from `Configuration::SeverityProfile::PROFILES`.
32
+ # - `since` — first version the rule shipped in.
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
36
+ def aliases
37
+ CheckRules::LEGACY_RULE_ALIASES.select { |_legacy, canonical| canonical == id }.keys
38
+ end
39
+
40
+ # Hash-shaped form for `--format=json` consumers. Keys are
41
+ # Strings so the payload is JSON-stable without a transform
42
+ # pass.
43
+ def to_h
44
+ {
45
+ "id" => id,
46
+ "aliases" => aliases,
47
+ "summary" => summary,
48
+ "fires_when" => fires_when,
49
+ "does_not_fire_when" => does_not_fire_when,
50
+ "suppression" => suppression,
51
+ "severity_authored" => severity_authored.to_s,
52
+ "severity_by_profile" => severity_by_profile.transform_keys(&:to_s).transform_values(&:to_s),
53
+ "since" => since
54
+ }
55
+ end
56
+ end
57
+
58
+ ENTRIES = {
59
+ CheckRules::RULE_UNDEFINED_METHOD => Entry.new(
60
+ id: CheckRules::RULE_UNDEFINED_METHOD,
61
+ summary: "Method does not exist on the receiver's statically-known class.",
62
+ fires_when: [
63
+ "The call is `receiver.method(...)` with an explicit receiver.",
64
+ "The receiver type resolves to `Type::Nominal` / `Singleton` / `Constant` / `Tuple` / `HashShape`.",
65
+ "The receiver class is RBS-known (declared in the loaded environment).",
66
+ "The user has not declared the method via `def` or recognised `define_method`.",
67
+ "Neither the receiver class nor an ancestor's RBS sig declares the method."
68
+ ],
69
+ does_not_fire_when: [
70
+ "Implicit-self calls (no receiver) — too noisy without per-method RBS for every helper.",
71
+ "Receiver is `Dynamic[T]` / `Top` / `Union` — by definition the method set isn't enumerable.",
72
+ "Receiver class is in the loader but its RBS definition cannot be built (constant aliases)."
73
+ ],
74
+ suppression: "`# rigor:disable call.undefined-method` on the call line, " \
75
+ "or `disable: [\"call.undefined-method\"]` in `.rigor.yml`.",
76
+ severity_authored: :error,
77
+ severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
78
+ since: "0.0.1"
79
+ ),
80
+
81
+ CheckRules::RULE_WRONG_ARITY => Entry.new(
82
+ id: CheckRules::RULE_WRONG_ARITY,
83
+ summary: "Call's positional argument count is outside the declared overloads' envelope.",
84
+ fires_when: [
85
+ "Call is `receiver.method(args...)` with explicit receiver + plain positional args.",
86
+ "Receiver class is RBS-known and the method has a definition.",
87
+ "Actual positional count is below the min or above the max across all overloads."
88
+ ],
89
+ does_not_fire_when: [
90
+ "Call uses `*splat`, keyword arguments, block-pass, or forwarded arguments.",
91
+ "Method declares required keyword arguments (caller must pass kwargs the rule doesn't model).",
92
+ "Method has a `*rest` positional parameter (max arity is unbounded)."
93
+ ],
94
+ suppression: "`# rigor:disable call.wrong-arity`.",
95
+ severity_authored: :error,
96
+ severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
97
+ since: "0.0.1"
98
+ ),
99
+
100
+ CheckRules::RULE_ARGUMENT_TYPE => Entry.new(
101
+ id: CheckRules::RULE_ARGUMENT_TYPE,
102
+ summary: "Call passes an argument whose type the parameter cannot accept.",
103
+ fires_when: [
104
+ "The parameter type rejects the argument under `accepts(arg, mode: :gradual)`.",
105
+ "Method has a single overload (multi-overload checking is deferred).",
106
+ "Both sides have a non-Dynamic concrete type."
107
+ ],
108
+ does_not_fire_when: [
109
+ "Either the parameter or the argument is `Dynamic[T]`.",
110
+ "Method has multiple overloads.",
111
+ "Method has `*rest_positionals`, required keywords, or trailing positionals."
112
+ ],
113
+ suppression: "`# rigor:disable call.argument-type-mismatch`.",
114
+ severity_authored: :error,
115
+ severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
116
+ since: "0.0.2"
117
+ ),
118
+
119
+ CheckRules::RULE_NIL_RECEIVER => Entry.new(
120
+ id: CheckRules::RULE_NIL_RECEIVER,
121
+ summary: "Receiver may be nil and the method is not defined on NilClass.",
122
+ fires_when: [
123
+ "Receiver type is `Type::Union` containing `Constant<nil>` (or `nil` from the RBS Optional).",
124
+ "The non-nil branch has the method, but `NilClass` does not.",
125
+ "Call is not safe-navigation (`x&.method`)."
126
+ ],
127
+ does_not_fire_when: [
128
+ "Method exists on every member of the union (including NilClass).",
129
+ "Receiver was narrowed via `return if x.nil?` / similar early-return guard.",
130
+ "Call uses safe-navigation (`x&.method`)."
131
+ ],
132
+ suppression: "`# rigor:disable call.possible-nil-receiver`.",
133
+ severity_authored: :error,
134
+ severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
135
+ since: "0.0.2"
136
+ ),
137
+
138
+ CheckRules::RULE_DUMP_TYPE => Entry.new(
139
+ id: CheckRules::RULE_DUMP_TYPE,
140
+ summary: "`dump_type(expr)` from Rigor::Testing — informational type print.",
141
+ fires_when: [
142
+ "Top-level / DSL-block call to `dump_type(expr)` after `include Rigor::Testing`."
143
+ ],
144
+ does_not_fire_when: [
145
+ "Outside a context that includes Rigor::Testing.",
146
+ "Argument is not a single expression."
147
+ ],
148
+ suppression: "Remove the `dump_type` call (it's a debug helper, not a real diagnostic).",
149
+ severity_authored: :info,
150
+ severity_by_profile: { lenient: :info, balanced: :info, strict: :error },
151
+ since: "0.0.1"
152
+ ),
153
+
154
+ CheckRules::RULE_ASSERT_TYPE => Entry.new(
155
+ id: CheckRules::RULE_ASSERT_TYPE,
156
+ summary: "`assert_type(\"<expected>\", expr)` from Rigor::Testing — type-equality check.",
157
+ fires_when: [
158
+ "Inferred type's display does not match the asserted string.",
159
+ "Useful in fixture self-assertions (every `spec/integration/fixtures/*.rb` uses it)."
160
+ ],
161
+ does_not_fire_when: [
162
+ "Inferred type matches the assertion exactly."
163
+ ],
164
+ suppression: "Update the assertion to the actual inferred type, or correct the source.",
165
+ severity_authored: :error,
166
+ severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
167
+ since: "0.0.1"
168
+ ),
169
+
170
+ CheckRules::RULE_ALWAYS_RAISES => Entry.new(
171
+ id: CheckRules::RULE_ALWAYS_RAISES,
172
+ summary: "Call provably raises (today: Integer division-by-zero).",
173
+ fires_when: [
174
+ "Receiver is `Integer` / `IntegerRange` / `Constant<Integer>`.",
175
+ "Operator is `/` / `%` / `div` / `modulo` / `divmod`.",
176
+ "Argument is a `Constant<Integer>` whose value is exactly zero."
177
+ ],
178
+ does_not_fire_when: [
179
+ "Receiver is Float / Rational (those return Infinity / NaN, not an exception).",
180
+ "Argument is a Union containing zero (\"may raise\" not \"always raises\")."
181
+ ],
182
+ suppression: "`# rigor:disable flow.always-raises`.",
183
+ severity_authored: :error,
184
+ severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
185
+ since: "0.0.3"
186
+ ),
187
+
188
+ CheckRules::RULE_UNREACHABLE_BRANCH => Entry.new(
189
+ id: CheckRules::RULE_UNREACHABLE_BRANCH,
190
+ summary: "An if / unless / ternary's literal predicate makes one branch dead.",
191
+ fires_when: [
192
+ "Predicate is a syntactic literal: `true` / `false` / `nil` / Integer / Float / String / Symbol / Regexp.",
193
+ "The corresponding dead branch carries a non-empty body."
194
+ ],
195
+ does_not_fire_when: [
196
+ "Predicate is an inferred-constant expression (not a literal). The literal-only envelope avoids " \
197
+ "false positives from Rigor's incomplete loop / mutation / RBS-strictness modelling.",
198
+ "The dead branch is empty (no useful location to point at)."
199
+ ],
200
+ suppression: "`# rigor:disable unreachable-branch` on the dead-branch line (the diagnostic " \
201
+ "points at the dead branch, not the predicate, so the suppression goes there).",
202
+ severity_authored: :warning,
203
+ severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
204
+ since: "0.1.2"
205
+ ),
206
+
207
+ CheckRules::RULE_ALWAYS_TRUTHY_CONDITION => Entry.new(
208
+ id: CheckRules::RULE_ALWAYS_TRUTHY_CONDITION,
209
+ summary: "An if / unless / ternary predicate's inferred type folds to a constant.",
210
+ fires_when: [
211
+ "Predicate's inferred type is `Type::Constant<true | false | nil | ...>`.",
212
+ "Predicate is NOT a syntactic literal (the literal-only `flow.unreachable-branch` rule covers those)."
213
+ ],
214
+ does_not_fire_when: [
215
+ "Predicate sits inside a `WhileNode` / `UntilNode` / `ForNode` / `BlockNode` ancestor — " \
216
+ "Rigor's mutation tracking through loop bodies is incomplete enough that an inferred " \
217
+ "`Constant<bool>` can be a false positive.",
218
+ "Predicate is a defensive `.nil?` / `.empty?` / `.zero?` / `.any?` / `.none?` / `.all?` / " \
219
+ "`.respond_to?` call — these typically fire when the user is being more cautious than the " \
220
+ "RBS strict-on-returns sig admits.",
221
+ "Predicate folds to a non-Constant type (Union / Nominal / Dynamic / etc.)."
222
+ ],
223
+ suppression: "`# rigor:disable always-truthy-condition` on the predicate line.",
224
+ severity_authored: :warning,
225
+ severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
226
+ since: "0.1.2"
227
+ ),
228
+
229
+ CheckRules::RULE_DEAD_ASSIGNMENT => Entry.new(
230
+ id: CheckRules::RULE_DEAD_ASSIGNMENT,
231
+ summary: "Local variable assigned in a method body but never read.",
232
+ fires_when: [
233
+ "Plain `LocalVariableWriteNode` (not `+=` / `||=` / multi-assign) inside a `DefNode` body.",
234
+ "The target name does not appear as a `LocalVariableReadNode` anywhere in the same body, " \
235
+ "including nested blocks / lambdas.",
236
+ "The write is not the last statement of the body (Ruby's implicit return)."
237
+ ],
238
+ does_not_fire_when: [
239
+ "Top-level / class-body assignments (their reachability spans the file's introspection / require surface).",
240
+ "The target name starts with `_` (Ruby convention for intentionally-unused).",
241
+ "The write is a destructure (`a, b = foo`) or operator-write (`x += 1` / `x ||= 1`).",
242
+ "The write is the last statement of the method body (assignments return their rvalue)."
243
+ ],
244
+ suppression: "`# rigor:disable dead-assignment` on the offending line, or rename the local to `_<name>`.",
245
+ severity_authored: :warning,
246
+ severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
247
+ since: "0.1.2"
248
+ ),
249
+
250
+ CheckRules::RULE_RETURN_TYPE => Entry.new(
251
+ id: CheckRules::RULE_RETURN_TYPE,
252
+ summary: "Method body's last-expression type is incompatible with the declared return type.",
253
+ fires_when: [
254
+ "Method has a `def` body the engine can re-type.",
255
+ "Method's RBS sig declares a non-`untyped` return type.",
256
+ "Body's inferred return type does not flow into the declared type under gradual acceptance.",
257
+ "When the RBS sig carries `%a{rigor:v1:return: <refinement>}` (v0.1.2), the refinement " \
258
+ "carrier — `non-empty-string`, `positive-int`, etc. — replaces the bare RBS class for the " \
259
+ "comparison, so a body the bare class would accept may still fail the refinement."
260
+ ],
261
+ does_not_fire_when: [
262
+ "Method's declared return is `untyped` / `void`.",
263
+ "Body's last expression is `Dynamic[T]` (the engine cannot rule out the declared type)."
264
+ ],
265
+ suppression: "`# rigor:disable def.return-type-mismatch`.",
266
+ severity_authored: :warning,
267
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
268
+ since: "0.1.0"
269
+ ),
270
+
271
+ CheckRules::RULE_VISIBILITY_MISMATCH => Entry.new(
272
+ id: CheckRules::RULE_VISIBILITY_MISMATCH,
273
+ summary: "Explicit-receiver call to a method declared `private` in source.",
274
+ fires_when: [
275
+ "Call is `receiver.method(...)` with explicit non-self receiver.",
276
+ "Receiver type resolves to `Type::Nominal[X]`.",
277
+ "X is a user-defined class whose source carries the method under `private`."
278
+ ],
279
+ does_not_fire_when: [
280
+ "Implicit-self call (no receiver) — always allowed for private.",
281
+ "Receiver is `self` (Ruby 2.7+ permits `self.private_method`).",
282
+ "Receiver class is RBS-known but not user-source-defined (RBS-side visibility is deferred).",
283
+ "Method is `:protected` (subclass tracking is deferred)."
284
+ ],
285
+ suppression: "`# rigor:disable method-visibility-mismatch`.",
286
+ severity_authored: :error,
287
+ severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
288
+ since: "0.1.2"
289
+ ),
290
+
291
+ CheckRules::RULE_IVAR_WRITE_MISMATCH => Entry.new(
292
+ id: CheckRules::RULE_IVAR_WRITE_MISMATCH,
293
+ summary: "Same instance variable assigned a different concrete class within one class.",
294
+ fires_when: [
295
+ "Two or more `@var = ...` writes occur in instance methods of the same class.",
296
+ "First write's rvalue resolves to a concrete class (Nominal / Singleton / Constant / Tuple → " \
297
+ "\"Array\" / HashShape → \"Hash\").",
298
+ "A later write's rvalue resolves to a different concrete class."
299
+ ],
300
+ does_not_fire_when: [
301
+ "Later write is `nil` — the `@cache = nil` clear-idiom is allowlisted.",
302
+ "Either side is Union / Dynamic / IntegerRange / a shape-varied carrier.",
303
+ "Writes live in different classes that happen to share an ivar name.",
304
+ "Writes are in `def self.foo` (singleton) bodies — those track separately."
305
+ ],
306
+ suppression: "`# rigor:disable ivar-write-mismatch` on the offending write.",
307
+ severity_authored: :error,
308
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
309
+ since: "0.1.2"
310
+ )
311
+ }.freeze
312
+
313
+ module_function
314
+
315
+ # Looks up a rule by canonical id, legacy alias, or family
316
+ # wildcard. Returns an Array<Entry>:
317
+ #
318
+ # - canonical id → 1-element array,
319
+ # - legacy alias → 1-element array (resolved to canonical),
320
+ # - family token (`call`) → every entry under that family,
321
+ # - unknown token → empty array.
322
+ def resolve(token)
323
+ token = token.to_s
324
+ return [ENTRIES.fetch(token)] if ENTRIES.key?(token)
325
+
326
+ if CheckRules::LEGACY_RULE_ALIASES.key?(token)
327
+ canonical = CheckRules::LEGACY_RULE_ALIASES.fetch(token)
328
+ return [ENTRIES.fetch(canonical)]
329
+ end
330
+
331
+ if CheckRules::RULE_FAMILIES.include?(token)
332
+ return ENTRIES.values.select { |entry| entry.id.start_with?("#{token}.") }.sort_by(&:id)
333
+ end
334
+
335
+ []
336
+ end
337
+
338
+ def all
339
+ ENTRIES.values.sort_by(&:id)
340
+ end
341
+ end
342
+ end
343
+ end
@@ -12,6 +12,7 @@ require_relative "../inference/coverage_scanner"
12
12
  require_relative "../inference/scope_indexer"
13
13
  require_relative "../inference/method_dispatcher/file_folding"
14
14
  require_relative "check_rules"
15
+ require_relative "dependency_source_inference"
15
16
  require_relative "diagnostic"
16
17
  require_relative "result"
17
18
 
@@ -21,7 +22,7 @@ module Rigor
21
22
  RUBY_GLOB = "**/*.rb"
22
23
  DEFAULT_CACHE_ROOT = ".rigor/cache"
23
24
 
24
- attr_reader :cache_store, :plugin_registry
25
+ attr_reader :cache_store, :plugin_registry, :dependency_source_index
25
26
 
26
27
  # @param configuration [Rigor::Configuration]
27
28
  # @param explain [Boolean] surface fail-soft fallback events
@@ -40,6 +41,7 @@ module Rigor
40
41
  @cache_store = cache_store
41
42
  @plugin_requirer = plugin_requirer
42
43
  @plugin_registry = Plugin::Registry::EMPTY
44
+ @dependency_source_index = DependencySourceInference::Index::EMPTY
43
45
  end
44
46
 
45
47
  # Walks every Ruby file under `paths`, parses it, builds a
@@ -58,22 +60,37 @@ module Rigor
58
60
  return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
59
61
 
60
62
  @plugin_registry = load_plugins
63
+ @dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
61
64
  environment = Environment.for_project(
62
65
  libraries: @configuration.libraries,
63
66
  signature_paths: @configuration.signature_paths,
64
67
  cache_store: @cache_store,
65
- plugin_registry: @plugin_registry
68
+ plugin_registry: @plugin_registry,
69
+ dependency_source_index: @dependency_source_index
66
70
  )
67
71
  expansion = expand_paths(paths)
68
72
 
69
- diagnostics = plugin_load_diagnostics
70
- diagnostics += plugin_prepare_diagnostics
71
- diagnostics += expansion.fetch(:errors)
73
+ diagnostics = pre_file_diagnostics(expansion)
72
74
  diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
73
75
 
74
76
  Result.new(diagnostics: apply_severity_profile(diagnostics))
75
77
  end
76
78
 
79
+ # Pre-file diagnostic streams that fire once per run rather
80
+ # than per analyzed file: plugin load / prepare envelopes,
81
+ # the ADR-10 dependency-source resolution surface, and the
82
+ # `expand_paths` errors for `paths:` entries that don't
83
+ # exist or aren't `.rb`. Aggregated here so `#run` stays
84
+ # under the ABC budget.
85
+ def pre_file_diagnostics(expansion)
86
+ plugin_load_diagnostics +
87
+ plugin_prepare_diagnostics +
88
+ dependency_source_diagnostics +
89
+ dependency_source_budget_diagnostics +
90
+ dependency_source_config_conflict_diagnostics +
91
+ expansion.fetch(:errors)
92
+ end
93
+
77
94
  # `target_ruby` flows through to Prism's `version:` option.
78
95
  # Prism enforces the supported range and raises
79
96
  # `ArgumentError` for versions it does not recognise. Run a
@@ -140,7 +157,8 @@ module Rigor
140
157
  Plugin::TrustPolicy.new(
141
158
  trusted_gems: trusted_gems,
142
159
  allowed_read_roots: roots,
143
- network_policy: @configuration.plugins_io_network
160
+ network_policy: @configuration.plugins_io_network,
161
+ allowed_url_hosts: @configuration.plugins_io_allowed_url_hosts
144
162
  )
145
163
  end
146
164
 
@@ -206,6 +224,78 @@ module Rigor
206
224
  end
207
225
  end
208
226
 
227
+ # ADR-10 § "Diagnostic prefix family" — surfaces gems
228
+ # listed in `dependencies.source_inference` that RubyGems
229
+ # could not resolve. The run continues; the gem simply
230
+ # contributes nothing this session, mirroring the
231
+ # plugin-load error envelope. Authored `:warning` because
232
+ # an unresolvable gem usually means a typo or a missing
233
+ # `bundle install` rather than a project-blocking problem;
234
+ # the severity profile still re-stamps it.
235
+ def dependency_source_diagnostics
236
+ @dependency_source_index.unresolvable.map do |entry|
237
+ Diagnostic.new(
238
+ path: ".rigor.yml",
239
+ line: 1,
240
+ column: 1,
241
+ message: "dependencies.source_inference[].gem #{entry.gem_name.inspect} could not be " \
242
+ "resolved (#{entry.reason}); skipping",
243
+ severity: :warning,
244
+ rule: "dynamic.dependency-source.gem-not-found",
245
+ source_family: :builtin
246
+ )
247
+ end
248
+ end
249
+
250
+ # ADR-10 § "Budget interaction" / slice 4 — emits one
251
+ # `:warning` per gem whose Walker run hit the
252
+ # `dependencies.budget_per_gem` cap. The cap is a Walker-
253
+ # side guard rail (slice 4 picks the (α) semantics from
254
+ # ADR-10 WD4: harvesting stops, the dispatcher behaves
255
+ # exactly as before for unrecorded methods). The
256
+ # diagnostic names the gem and points the user at the
257
+ # three remediations: ship RBS, reduce `mode:` from
258
+ # `full` to `when_missing`, or de-list the gem.
259
+ # ADR-10 § "config-conflict diagnostic" / 5d — surfaces
260
+ # `Configuration::Dependencies` warnings accumulated
261
+ # during `from_h` deduplication of the `includes:`-chain
262
+ # source_inference array. Each warning describes a
263
+ # per-gem mode conflict that the merge resolved
264
+ # right-wins; the user sees one diagnostic per conflict.
265
+ # `:warning` matches the user's "warn but don't block"
266
+ # preference per the design discussion.
267
+ def dependency_source_config_conflict_diagnostics
268
+ @configuration.dependencies.warnings.map do |message|
269
+ Diagnostic.new(
270
+ path: ".rigor.yml",
271
+ line: 1,
272
+ column: 1,
273
+ message: message,
274
+ severity: :warning,
275
+ rule: "dynamic.dependency-source.config-conflict",
276
+ source_family: :builtin
277
+ )
278
+ end
279
+ end
280
+
281
+ def dependency_source_budget_diagnostics
282
+ budget = @configuration.dependencies.budget_per_gem
283
+ @dependency_source_index.budget_exceeded.map do |gem_name|
284
+ Diagnostic.new(
285
+ path: ".rigor.yml",
286
+ line: 1,
287
+ column: 1,
288
+ message: "dependencies.source_inference[].gem #{gem_name.inspect} exceeded the per-gem " \
289
+ "catalog cap (#{budget} method definitions); the remaining methods fall back " \
290
+ "to the existing RBS-or-Dynamic[top] boundary. Ship RBS for the gem, set " \
291
+ "`mode: when_missing` instead of `full`, or de-list the gem.",
292
+ severity: :warning,
293
+ rule: "dynamic.dependency-source.budget-exceeded",
294
+ source_family: :builtin
295
+ )
296
+ end
297
+ end
298
+
209
299
  # ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
210
300
  # hook once per run, after the loader's `#init` pass and
211
301
  # before per-file iteration. Plugins publish facts here
@@ -24,8 +24,10 @@ module Rigor
24
24
  class Descriptor # rubocop:disable Metrics/ClassLength
25
25
  # Bumped on incompatible schema changes. The storage layer
26
26
  # mixes this into the cache key, so a bump implicitly
27
- # invalidates every cached value.
28
- SCHEMA_VERSION = 1
27
+ # invalidates every cached value. v2 added the
28
+ # `dependencies` slot for ADR-10 per-gem-version cache slice
29
+ # invalidation.
30
+ SCHEMA_VERSION = 2
29
31
 
30
32
  # Per-slot entry value objects. Constructors validate enums /
31
33
  # required fields and freeze the resulting struct so no caller
@@ -134,6 +136,54 @@ module Rigor
134
136
  end
135
137
  end
136
138
 
139
+ # Per-(gem, version, mode) row carrying the cache slice
140
+ # boundary for ADR-10 dependency-source inference. A
141
+ # `bundle update` that bumps a listed gem's pinned version
142
+ # produces a different `gem_version` here and therefore a
143
+ # fresh cache key — invalidating exactly that gem's slice
144
+ # without disturbing other gems' slices or the project's
145
+ # own cache.
146
+ #
147
+ # `mode` mirrors the
148
+ # [Configuration::Dependencies::VALID_MODES](../configuration/dependencies.rb)
149
+ # enum (`:disabled` / `:when_missing` / `:full`); a mode
150
+ # change for the same gem also forces invalidation because
151
+ # the inferred shapes depend on whether RBS overrides the
152
+ # walk.
153
+ class DependencyEntry
154
+ VALID_MODES = %i[disabled when_missing full].freeze
155
+
156
+ attr_reader :gem_name, :gem_version, :mode
157
+
158
+ def initialize(gem_name:, gem_version:, mode:)
159
+ unless VALID_MODES.include?(mode)
160
+ raise ArgumentError,
161
+ "DependencyEntry mode must be one of #{VALID_MODES.inspect}, got #{mode.inspect}"
162
+ end
163
+
164
+ @gem_name = gem_name.to_s.dup.freeze
165
+ @gem_version = gem_version.to_s.dup.freeze
166
+ @mode = mode
167
+ freeze
168
+ end
169
+
170
+ def to_h
171
+ { "gem_name" => gem_name, "gem_version" => gem_version, "mode" => mode.to_s }
172
+ end
173
+
174
+ def ==(other)
175
+ other.is_a?(DependencyEntry) &&
176
+ other.gem_name == gem_name &&
177
+ other.gem_version == gem_version &&
178
+ other.mode == mode
179
+ end
180
+ alias eql? ==
181
+
182
+ def hash
183
+ [self.class, gem_name, gem_version, mode].hash
184
+ end
185
+ end
186
+
137
187
  # Raised when {.compose} encounters incompatible entries
138
188
  # under the same key (file digest mismatch, gem-locked
139
189
  # disagreement, …). Callers handle the exception by
@@ -141,13 +191,14 @@ module Rigor
141
191
  # contribution silently.
142
192
  class Conflict < StandardError; end
143
193
 
144
- attr_reader :files, :gems, :plugins, :configs
194
+ attr_reader :files, :gems, :plugins, :configs, :dependencies
145
195
 
146
- def initialize(files: [], gems: [], plugins: [], configs: [])
196
+ def initialize(files: [], gems: [], plugins: [], configs: [], dependencies: [])
147
197
  @files = files.dup.freeze
148
198
  @gems = gems.dup.freeze
149
199
  @plugins = plugins.dup.freeze
150
200
  @configs = configs.dup.freeze
201
+ @dependencies = dependencies.dup.freeze
151
202
  freeze
152
203
  end
153
204
 
@@ -170,7 +221,8 @@ module Rigor
170
221
  gems = compose_by_key(descriptors.flat_map(&:gems), :name)
171
222
  plugins = compose_by_key(descriptors.flat_map(&:plugins), :id)
172
223
  configs = compose_by_key(descriptors.flat_map(&:configs), :key)
173
- new(files: files, gems: gems, plugins: plugins, configs: configs)
224
+ dependencies = compose_by_key(descriptors.flat_map(&:dependencies), :gem_name)
225
+ new(files: files, gems: gems, plugins: plugins, configs: configs, dependencies: dependencies)
174
226
  end
175
227
 
176
228
  # @param producer_id [String]
@@ -196,6 +248,7 @@ module Rigor
196
248
  def to_canonical_hash
197
249
  {
198
250
  "configs" => sort_entries(configs, "key").map(&:to_h),
251
+ "dependencies" => sort_entries(dependencies, "gem_name").map(&:to_h),
199
252
  "files" => sort_entries(files, "path").map(&:to_h),
200
253
  "gems" => sort_entries(gems, "name").map(&:to_h),
201
254
  "plugins" => sort_entries(plugins, "id").map(&:to_h)