rigortype 0.4.3 → 0.4.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +16 -0
  5. data/docs/manual/02-cli-reference.md +4 -3
  6. data/docs/manual/03-configuration.md +16 -5
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/07-plugins.md +1 -1
  9. data/docs/manual/10-mcp-server.md +32 -3
  10. data/docs/manual/18-removing-dead-code.md +11 -1
  11. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  12. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  13. data/exe/rigor +12 -17
  14. data/lib/rigor/analysis/check_rules/rule_ids.rb +57 -0
  15. data/lib/rigor/analysis/check_rules.rb +46 -36
  16. data/lib/rigor/analysis/incremental.rb +12 -0
  17. data/lib/rigor/analysis/incremental_session.rb +121 -21
  18. data/lib/rigor/analysis/reachability/graph.rb +23 -3
  19. data/lib/rigor/analysis/reachability/scan.rb +51 -9
  20. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  21. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  22. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +110 -15
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  26. data/lib/rigor/analysis/runner.rb +35 -9
  27. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  28. data/lib/rigor/analysis/worker_session.rb +57 -1
  29. data/lib/rigor/cache/descriptor.rb +40 -6
  30. data/lib/rigor/cache/incremental_snapshot.rb +20 -4
  31. data/lib/rigor/cli/mcp_command.rb +1 -1
  32. data/lib/rigor/config_audit.rb +111 -15
  33. data/lib/rigor/configuration/dependencies.rb +19 -11
  34. data/lib/rigor/environment/rbs_loader.rb +46 -10
  35. data/lib/rigor/inference/block_call_timing.rb +5 -0
  36. data/lib/rigor/inference/captured_locals.rb +26 -3
  37. data/lib/rigor/inference/definer_resolution.rb +16 -7
  38. data/lib/rigor/inference/expression_typer.rb +59 -4
  39. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  40. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  41. data/lib/rigor/inference/module_function_state.rb +80 -13
  42. data/lib/rigor/inference/narrowing.rb +184 -68
  43. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  44. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  45. data/lib/rigor/inference/scope_indexer.rb +439 -70
  46. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  47. data/lib/rigor/inference/statement_evaluator.rb +84 -29
  48. data/lib/rigor/lens/anchor.rb +3 -3
  49. data/lib/rigor/lens/contract_reader.rb +15 -0
  50. data/lib/rigor/lens/document.rb +2 -2
  51. data/lib/rigor/lens/file_reader.rb +442 -73
  52. data/lib/rigor/lens/members.rb +65 -15
  53. data/lib/rigor/lens/run.rb +44 -2
  54. data/lib/rigor/lens.rb +2 -1
  55. data/lib/rigor/mcp/server.rb +61 -20
  56. data/lib/rigor/plugin/box_probe.rb +154 -7
  57. data/lib/rigor/project_environment.rb +6 -3
  58. data/lib/rigor/scope.rb +11 -6
  59. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  60. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  61. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  62. data/lib/rigor/sig_gen/writer.rb +47 -34
  63. data/lib/rigor/version.rb +1 -1
  64. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  65. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  66. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  67. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  68. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  69. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  70. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  71. metadata +4 -1
@@ -82,7 +82,12 @@ module Rigor
82
82
  # an unchanged file would fold as "no possible fact" where a cold walk records one.
83
83
  # v18: ADR-119 WD3 — the visibility walk fills `contested_discovered_method_visibilities`; a pre-18 bundle
84
84
  # carries it empty, so an unchanged file's conditional visibility would fold as certain.
85
- SCHEMA_VERSION = 18
85
+ # v19: #1555 / #1962 — the run-result dependency descriptor now carries a `:kind` row per analysis root,
86
+ # `pre_eval:` entry and project signature root (configured, or the auto-detected `sig`), so one that
87
+ # appears, vanishes or changes kind invalidates the warm entry. A
88
+ # pre-19 entry carries none of them and would validate fresh across exactly those edits, so cached
89
+ # entries must read as misses once and rebuild carrying the rows.
90
+ SCHEMA_VERSION = 19
86
91
 
87
92
  # Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
88
93
  # struct so no caller can mutate after the entry is in a Descriptor.
@@ -96,7 +101,7 @@ module Rigor
96
101
  # It MUST NOT be placed in a descriptor used as a cache KEY (its value carries machine-local,
97
102
  # per-run-nondeterministic stat data); {RbsDescriptor.build}'s env-cache `files` therefore stay
98
103
  # `:digest`, while the {Runner} run-dependency descriptor and plugin {IoBoundary} reads use `:stat`.
99
- VALID_COMPARATORS = %i[digest stat mtime exists].freeze
104
+ VALID_COMPARATORS = %i[digest stat mtime kind exists].freeze
100
105
 
101
106
  # The two `:exists` values — `File.exist?`'s answer rendered with `to_s`, which is what
102
107
  # {Descriptor#file_entry_fresh?} compares the live answer against.
@@ -163,6 +168,32 @@ module Rigor
163
168
  new(path: path, comparator: :exists, value: PRESENT)
164
169
  end
165
170
 
171
+ # The four `:kind` values, in the order {Analysis::Runner}'s path expansion asks: a directory, a regular
172
+ # file, something else that exists (a FIFO, a socket), nothing (a missing path or a dangling symlink).
173
+ # Each predicate follows symlinks, as the expansion's do.
174
+ KIND_DIRECTORY = "directory"
175
+ KIND_FILE = "file"
176
+ KIND_OTHER = "other"
177
+ KIND_ABSENT = "absent"
178
+
179
+ # #1962 — the kind row: fresh while `path` is still the same kind of thing, stale once it appears,
180
+ # vanishes, or a directory and a file trade places. It is the row for an analysis root, whose
181
+ # diagnostics (`no such file or directory`, `not a Ruby file`) answer to exactly that and to nothing
182
+ # else about the path, and for a `pre_eval:` entry and a signature root: an existence row would read a
183
+ # directory replaced by a file of the same name as fresh (ADR-45 WD1b's bound), and what the path holds
184
+ # has rows of its own (the analysed-file set the key carries, a signature root's `.rbs` rows).
185
+ def self.kind(path:)
186
+ new(path: path, comparator: :kind, value: kind_of(path))
187
+ end
188
+
189
+ def self.kind_of(path)
190
+ if File.directory?(path) then KIND_DIRECTORY
191
+ elsif File.file?(path) then KIND_FILE
192
+ elsif File.exist?(path) then KIND_OTHER
193
+ else KIND_ABSENT
194
+ end
195
+ end
196
+
166
197
  # @return whether this row records the path as absent (see {.absent}).
167
198
  def absent?
168
199
  comparator == :exists && value == ABSENT
@@ -413,10 +444,11 @@ module Rigor
413
444
 
414
445
  # File-comparator strictness ordering. `:stat` is strictest (it carries the content digest AND a stat
415
446
  # tuple, so it validates content-authoritatively while short-circuiting on an unmoved stat); `:digest` is
416
- # deterministic across machines; `:mtime` is cheaper but local; `:exists` is the weakest signal. Ranking
417
- # `:stat` and `:digest` apart guarantees a path contributed under both never raises a value {Conflict}
418
- # (their `value` strings differ by construction) — the stricter one wins and its value is used.
419
- COMPARATOR_STRICTNESS = { stat: 3, digest: 2, mtime: 1, exists: 0 }.freeze
447
+ # deterministic across machines; `:mtime` is cheaper but local; `:kind` answers what the path is and
448
+ # `:exists` only whether it is there, the weakest signal. Ranking `:stat` and `:digest` apart guarantees a
449
+ # path contributed under both never raises a value {Conflict} (their `value` strings differ by
450
+ # construction) — the stricter one wins and its value is used.
451
+ COMPARATOR_STRICTNESS = { stat: 4, digest: 3, mtime: 2, kind: 1, exists: 0 }.freeze
420
452
  private_constant :COMPARATOR_STRICTNESS
421
453
 
422
454
  # Composes any number of descriptors into a single descriptor whose slots are the union of the inputs'
@@ -505,6 +537,8 @@ module Rigor
505
537
  FileDigest.stat_fresh?(entry.path, entry.value)
506
538
  when :mtime
507
539
  File.exist?(entry.path) && File.mtime(entry.path).to_i.to_s == entry.value
540
+ when :kind
541
+ FileEntry.kind_of(entry.path) == entry.value
508
542
  when :exists
509
543
  File.exist?(entry.path).to_s == entry.value
510
544
  else
@@ -169,7 +169,10 @@ module Rigor
169
169
  # file's conditional `def` as certain and a warm run would answer a definer a cold run declines.
170
170
  # 37: ADR-119 WD3 — the visibility walk fills `contested_discovered_method_visibilities`, which a pre-37 bundle
171
171
  # carries empty.
172
- SCHEMA = 37
172
+ # 38: issue #1898 — the payload gains `effect_plugin_digest`, the plugin effect-fact digest the collections were
173
+ # stamped under, which a recheck now checks before its served collections reach the envelope pass. A pre-38
174
+ # blob carries none, so the gate rejects it and the next run is cold.
175
+ SCHEMA = 38
173
176
 
174
177
  # The persisted per-file state.
175
178
  # `cache` maps an analyzed file to its diagnostics.
@@ -224,11 +227,22 @@ module Rigor
224
227
  # Issues #796 / #794 — the run-level rows a narrowed run replays instead of re-deriving; see
225
228
  # docs/internal-spec/cache.md § "Run-level rows the snapshot carries" for why exactly these two are
226
229
  # fresh under the fingerprint and why no other `.rigor.yml`-level row may join them.
230
+ # Issue #1898 — `effect_plugin_digest` is {Rigor::Effects::PluginFacts#digest} at the run that collected
231
+ # `effect_collections` (nil when collection was off). A plugin stamps its attributions and edges into a file's
232
+ # collection, and `effects_identity` is plugin-blind (it is compared before any plugin loads), so the session
233
+ # compares this one after the recheck has loaded them.
227
234
  Payload = Data.define(:cache, :sources, :digests, :analyzed,
228
235
  :symbol_sources, :ancestry_sources, :symbol_fingerprints,
229
236
  :missing, :class_decls, :constant_decls, :seed_bundles, :plugin_fact_digest,
230
237
  :return_summaries, :param_table,
231
- :effect_collections, :effects_identity, :run_level_rows)
238
+ :effect_collections, :effects_identity, :run_level_rows,
239
+ :effect_plugin_digest) do
240
+ # Defaults to nil so a payload built without it (an older caller, a spec fixture) reads as "unknown",
241
+ # which the session treats as moved.
242
+ def initialize(effect_plugin_digest: nil, **rest)
243
+ super
244
+ end
245
+ end
232
246
 
233
247
  # Issues #796 / #794 — the two run-level rows the snapshot carries. `definition_build_failures` is the
234
248
  # reported per-class detail list ({Environment::RbsLoader#definition_build_failures}); `hkt_scan_failure`
@@ -434,7 +448,8 @@ module Rigor
434
448
  param_table: data[:param_table] || {},
435
449
  effect_collections: data[:effect_collections] || {},
436
450
  effects_identity: data[:effects_identity],
437
- run_level_rows: data[:run_level_rows]
451
+ run_level_rows: data[:run_level_rows],
452
+ effect_plugin_digest: data[:effect_plugin_digest]
438
453
  )
439
454
  end
440
455
  private :payload_from
@@ -459,7 +474,8 @@ module Rigor
459
474
  param_table: payload.param_table,
460
475
  effect_collections: payload.effect_collections,
461
476
  effects_identity: payload.effects_identity,
462
- run_level_rows: payload.run_level_rows
477
+ run_level_rows: payload.run_level_rows,
478
+ effect_plugin_digest: payload.effect_plugin_digest
463
479
  )
464
480
  # #1507 — every `--incremental` run that analysed anything rewrites this whole blob, so its deflate is
465
481
  # on the edit path; see {Store::DEFLATE_LEVEL}.
@@ -12,7 +12,7 @@ module Rigor
12
12
  # Starts a long-running MCP (Model Context Protocol) server over stdio. The server exposes Rigor's analysis tools as
13
13
  # MCP tool calls over a newline-delimited JSON-RPC 2.0 stream. See ADR-33.
14
14
  #
15
- # Slice 1 ships the stdio transport with seven read-only tools: rigor_check, rigor_type_of, rigor_triage,
15
+ # The stdio transport serves eight read-only tools: rigor_check, rigor_type_of, rigor_triage, rigor_lens,
16
16
  # rigor_annotate, rigor_sig_gen, rigor_explain, rigor_coverage.
17
17
  class McpCommand < Command
18
18
  USAGE = "Usage: rigor mcp [options]"
@@ -26,11 +26,12 @@ module Rigor
26
26
  #
27
27
  # Every check is held to the same bar that {SignaturePathAudit} set: it mirrors the
28
28
  # loader's own acceptance test, so a warning means the loader really did load nothing, and
29
- # it never fires on a setup that works. In particular the rule-token check only flags a
30
- # token under a built-in family (`call`, `flow`, …) — a plugin- or `rbs_extended.*` rule id
31
- # (whose family Rigor cannot statically enumerate, since a `node_rule` block emits any
32
- # `rule:` string it likes) is left alone, so disabling a plugin rule is never mistaken for
33
- # a typo.
29
+ # it never fires on a setup that works. In particular the `severity_overrides:` check only
30
+ # flags a key under a built-in family (`call`, `flow`, …) — a plugin- or `rbs_extended.*`
31
+ # rule id (whose family Rigor cannot statically enumerate, since a `node_rule` block emits
32
+ # any `rule:` string it likes) is left alone. The `disable:` check goes further because the
33
+ # list's reach is enumerable (issue #1953): a plugin's row answers only to its qualified
34
+ # `plugin.<id>.<rule>` id, so a bare plugin rule there is inert (see {.disable_token_warning}).
34
35
  module ConfigAudit
35
36
  # One config-level finding. `kind` discriminates the source key (`:signature_path`,
36
37
  # `:bundled_plugin_signature_path`, `:library`, `:disabled_rule`, `:severity_override`,
@@ -146,18 +147,13 @@ module Rigor
146
147
  []
147
148
  end
148
149
 
149
- # `disable:` tokens and `severity_overrides:` keys that name no rule. Restricted to
150
- # tokens under a built-in family so a plugin rule id is never mis-flagged (see the module
151
- # docstring).
150
+ # `disable:` tokens that reach no row ({.disable_token_warning}) and `severity_overrides:` keys
151
+ # that name no rule. An override key is flagged only under a built-in family, so a plugin
152
+ # rule id is never mis-flagged (see the module docstring).
152
153
  def self.rule_token_warnings(configuration)
154
+ plugin_ids = configured_plugin_ids(configuration.plugins)
153
155
  disable = Array(configuration.disabled_rules).filter_map do |token|
154
- next unless inert_builtin_token?(token.to_s)
155
-
156
- Warning.new(
157
- kind: :disabled_rule,
158
- message: "disable: #{token.to_s.inspect} is not a recognized rule id; the suppression has no effect",
159
- fields: { "token" => token.to_s }
160
- )
156
+ disable_token_warning(token.to_s, plugin_ids)
161
157
  end
162
158
  overrides = configuration.severity_overrides.keys.filter_map do |key|
163
159
  next unless inert_builtin_token?(key.to_s)
@@ -171,6 +167,106 @@ module Rigor
171
167
  disable + overrides
172
168
  end
173
169
 
170
+ # Issue #1953 — the run-level rows the diagnostic policy keeps on `severity_overrides:` and the
171
+ # baseline. `disable:` does not reach them by any spelling, so naming one there is inert.
172
+ RESERVED_RUN_LEVEL_FAMILIES = %w[plugin_loader plugin_trust rbs.coverage dynamic.dependency-source].freeze
173
+ RESERVED_RUN_LEVEL_IDS = %w[load-error runtime-error pool-degraded].freeze
174
+ private_constant :RESERVED_RUN_LEVEL_FAMILIES, :RESERVED_RUN_LEVEL_IDS
175
+
176
+ # A warning for a `disable:` token that can reach no row, or nil. Since issue #1953 the list's
177
+ # reach is exactly enumerable for three shapes, and those are what this flags:
178
+ #
179
+ # - a token naming a reserved run-level row ({RESERVED_RUN_LEVEL_FAMILIES} /
180
+ # {RESERVED_RUN_LEVEL_IDS}), which the list never reaches;
181
+ # - a `plugin.`-qualified token naming no plugin `plugins:` configures — checked only when
182
+ # every entry's manifest id is known without loading it ({.configured_plugin_ids});
183
+ # - any other token that is not `all`, a family, a legacy alias or a catalogue / `pre-eval.*`
184
+ # id. A bare id never reaches a plugin's row, so a plugin's bare rule is inert here too.
185
+ #
186
+ # A token under a non-check engine family (`rbs_extended.*`, `rbs.*`, `dynamic.*`) or a known
187
+ # non-catalogue engine id stays unflagged: under-warning is this module's FP-safe direction.
188
+ def self.disable_token_warning(token, plugin_ids)
189
+ return nil if effective_disable_token?(token)
190
+
191
+ reason = disable_token_problem(token, plugin_ids)
192
+ return nil if reason.nil?
193
+
194
+ Warning.new(kind: :disabled_rule, message: "disable: #{token.inspect} #{reason}",
195
+ fields: { "token" => token })
196
+ end
197
+
198
+ def self.disable_token_problem(token, plugin_ids)
199
+ if reserved_run_level_token?(token)
200
+ "names a run-level row, which `disable:` does not reach; use `severity_overrides:` or a baseline"
201
+ elsif token.start_with?(Analysis::CheckRules::PLUGIN_FAMILY_PREFIX)
202
+ unless plugin_ids.nil? || plausible_plugin_token?(token, plugin_ids)
203
+ "names no plugin `plugins:` configures; the suppression has no effect"
204
+ end
205
+ elsif inert_builtin_token?(token)
206
+ "is not a recognized rule id; the suppression has no effect"
207
+ elsif !non_check_engine_token?(token)
208
+ "is not a recognized rule id; the suppression has no effect " \
209
+ "(if this is a plugin's rule, write it as `plugin.<id>.<rule>`)"
210
+ end
211
+ end
212
+ private_class_method :disable_token_problem
213
+
214
+ def self.effective_disable_token?(token)
215
+ return true if token == "all"
216
+
217
+ !Array(Analysis::CheckRules.resolve_rule_token(token)).empty?
218
+ end
219
+ private_class_method :effective_disable_token?
220
+
221
+ def self.reserved_run_level_token?(token)
222
+ RESERVED_RUN_LEVEL_IDS.include?(token) ||
223
+ RESERVED_RUN_LEVEL_FAMILIES.any? { |family| token == family || token.start_with?("#{family}.") }
224
+ end
225
+ private_class_method :reserved_run_level_token?
226
+
227
+ def self.non_check_engine_token?(token)
228
+ Analysis::CheckRules::NON_CHECK_DIAGNOSTIC_IDS.include?(token) ||
229
+ Analysis::CheckRules::NON_CHECK_DIAGNOSTIC_FAMILIES.include?(token.split(".").first)
230
+ end
231
+ private_class_method :non_check_engine_token?
232
+
233
+ # `plugin.<id>` or `plugin.<id>.<rule>` for a configured id. A manifest id may itself hold a
234
+ # dot, so the token is matched against each id rather than split.
235
+ def self.plausible_plugin_token?(token, plugin_ids)
236
+ rest = token.delete_prefix(Analysis::CheckRules::PLUGIN_FAMILY_PREFIX)
237
+ plugin_ids.any? { |id| rest == id || rest.start_with?("#{id}.") }
238
+ end
239
+ private_class_method :plausible_plugin_token?
240
+
241
+ # The manifest id of every `plugins:` entry, or nil when one cannot be known without loading
242
+ # the plugin. An explicit `id:` is the id; a bundled plugin's id is its gem name minus the
243
+ # `rigor-` prefix ({Plugin::FirstParty}); a third-party gem named without `id:` may register
244
+ # any id, so its presence turns the plugin-token check off. An `enabled: false` entry counts:
245
+ # the user has found the plugin, and under-warning is the FP-safe direction.
246
+ def self.configured_plugin_ids(plugins)
247
+ Array(plugins).each_with_object(Set.new) do |raw, ids|
248
+ entry = raw.is_a?(Hash) ? raw.to_h { |key, value| [key.to_s, value] } : { "gem" => raw.to_s }
249
+ if entry["id"]
250
+ ids << entry["id"].to_s
251
+ elsif bundled_plugin_gem?(entry["gem"].to_s)
252
+ ids << entry["gem"].to_s.delete_prefix(BUNDLED_GEM_PREFIX)
253
+ else
254
+ return nil
255
+ end
256
+ end
257
+ end
258
+ private_class_method :configured_plugin_ids
259
+
260
+ BUNDLED_GEM_PREFIX = "rigor-"
261
+ private_constant :BUNDLED_GEM_PREFIX
262
+
263
+ def self.bundled_plugin_gem?(gem)
264
+ return false unless gem.start_with?(BUNDLED_GEM_PREFIX)
265
+
266
+ File.file?(File.join(SignaturePathAudit::BUNDLED_PLUGINS_ROOT, gem, "lib", "#{gem}.rb"))
267
+ end
268
+ private_class_method :bundled_plugin_gem?
269
+
174
270
  # True when `token` looks like a built-in rule id but matches none — its first segment
175
271
  # is a built-in family yet it is neither a bare family wildcard nor a known canonical id.
176
272
  # A token whose family is not built-in (a plugin / `rbs_extended.*` rule, or a bare
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../configuration_error"
4
+
3
5
  module Rigor
4
6
  class Configuration
5
7
  # Parsed `dependencies:` section of `.rigor.yml`. Per
@@ -56,7 +58,7 @@ module Rigor
56
58
  # Hash with `source_inference:` and / or `budget_per_gem:` / `budget_overrun_strategy:` present.
57
59
  def self.from_h(data)
58
60
  return new([]) if data.nil?
59
- raise ArgumentError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
61
+ raise ConfigurationError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
60
62
 
61
63
  raw_entries = coerce_source_inference(data["source_inference"])
62
64
  entries, warnings = dedupe_entries(raw_entries)
@@ -139,7 +141,7 @@ module Rigor
139
141
  return [] if value.nil? || value == false
140
142
 
141
143
  unless value.is_a?(Array)
142
- raise ArgumentError,
144
+ raise ConfigurationError,
143
145
  "dependencies.source_inference: must be a list of entries " \
144
146
  "(or false / omitted to disable), got #{value.inspect}"
145
147
  end
@@ -149,7 +151,7 @@ module Rigor
149
151
 
150
152
  def coerce_entry(raw)
151
153
  unless raw.is_a?(Hash)
152
- raise ArgumentError,
154
+ raise ConfigurationError,
153
155
  "dependencies.source_inference[] entry must be a Hash, got #{raw.inspect}"
154
156
  end
155
157
 
@@ -162,7 +164,7 @@ module Rigor
162
164
 
163
165
  def coerce_gem(value)
164
166
  unless value.is_a?(String) && !value.empty?
165
- raise ArgumentError,
167
+ raise ConfigurationError,
166
168
  "dependencies.source_inference[].gem must be a non-empty String, got #{value.inspect}"
167
169
  end
168
170
 
@@ -170,28 +172,34 @@ module Rigor
170
172
  end
171
173
 
172
174
  def coerce_mode(value)
173
- mode = (value || "when_missing").to_sym
175
+ mode = config_symbol(value || "when_missing")
174
176
  return mode if VALID_MODES.include?(mode)
175
177
 
176
- raise ArgumentError,
178
+ raise ConfigurationError,
177
179
  "dependencies.source_inference[].mode must be one of " \
178
180
  "#{VALID_MODES.inspect}, got #{value.inspect}"
179
181
  end
180
182
 
183
+ # The Symbol a String / Symbol value names, or nil for any other YAML shape (a number, a list, a mapping), which
184
+ # the caller then reports as not one of its valid values rather than crashing on `#to_sym`.
185
+ def config_symbol(value)
186
+ value.to_sym if value.is_a?(String) || value.is_a?(Symbol)
187
+ end
188
+
181
189
  def coerce_roots(raw)
182
190
  roots = Array(raw.fetch("roots", DEFAULT_ROOTS)).map(&:to_s).freeze
183
191
  return roots unless roots.empty?
184
192
 
185
- raise ArgumentError,
193
+ raise ConfigurationError,
186
194
  "dependencies.source_inference[].roots must not be empty when supplied " \
187
195
  "(omit the key to fall back to the default #{DEFAULT_ROOTS.inspect})"
188
196
  end
189
197
 
190
198
  def coerce_budget_overrun_strategy(value)
191
- symbol = value.to_sym
199
+ symbol = config_symbol(value)
192
200
  return symbol if VALID_BUDGET_OVERRUN_STRATEGIES.include?(symbol)
193
201
 
194
- raise ArgumentError,
202
+ raise ConfigurationError,
195
203
  "dependencies.budget_overrun_strategy must be one of " \
196
204
  "#{VALID_BUDGET_OVERRUN_STRATEGIES.inspect}, got #{value.inspect}"
197
205
  end
@@ -201,13 +209,13 @@ module Rigor
201
209
  # MIN_BUDGET_PER_GEM .. MAX_BUDGET_PER_GEM (i.e. 0.25× – 4× of the default).
202
210
  def coerce_budget_per_gem(value)
203
211
  unless value.is_a?(Integer)
204
- raise ArgumentError,
212
+ raise ConfigurationError,
205
213
  "dependencies.budget_per_gem must be an Integer, " \
206
214
  "got #{value.inspect}"
207
215
  end
208
216
 
209
217
  unless value.between?(MIN_BUDGET_PER_GEM, MAX_BUDGET_PER_GEM)
210
- raise ArgumentError,
218
+ raise ConfigurationError,
211
219
  "dependencies.budget_per_gem must be in the range " \
212
220
  "#{MIN_BUDGET_PER_GEM}..#{MAX_BUDGET_PER_GEM}, " \
213
221
  "got #{value.inspect}"
@@ -66,6 +66,15 @@ module Rigor
66
66
  QUARANTINE_WARN_LIMIT = 10
67
67
 
68
68
  class << self
69
+ # Issue #1902 — the fixed total order that picks ONE detail tuple for a class out of several: its two
70
+ # sides in {#report_definition_build_failure}, or a fresh and a replayed record in
71
+ # {Analysis::Runner::PoolCoordinator}. Content-only (the tuple carries no side), so both places choose
72
+ # the same tuple from the same candidates, whatever order those candidates arrived in.
73
+ def definition_build_detail_order(detail)
74
+ _, error_class, member, buffers, unresolved = detail
75
+ [error_class.to_s, member.to_s, Array(buffers).map(&:to_s), Array(unresolved).map(&:to_s)]
76
+ end
77
+
69
78
  def default
70
79
  @default ||= new.freeze
71
80
  end
@@ -2471,6 +2480,18 @@ module Rigor
2471
2480
  # fail-soft: a broken RBS environment yields no entries.
2472
2481
  end
2473
2482
 
2483
+ # ADR-113 — the RBS declaration of the constant `name` (qualified) as `[type, buffer name]`, or nil when no
2484
+ # signature declares it. `rigor lens` reports the declared type with the provenance the buffer names.
2485
+ def constant_declaration(name)
2486
+ rbs_name = parse_type_name(name)
2487
+ entry = rbs_name && env&.constant_decls&.[](rbs_name)
2488
+ return nil unless entry
2489
+
2490
+ [entry.decl.type.to_s, entry.decl.location&.buffer&.name.to_s]
2491
+ rescue ::RBS::BaseError
2492
+ nil
2493
+ end
2494
+
2474
2495
  # Slice A constant-value lookup. Returns the translated `Rigor::Type` for a non-class constant
2475
2496
  # declaration (`BUCKETS: Array[Symbol]`, `DEFAULT_PATH: String`, ...) or `nil` when no constant entry
2476
2497
  # exists for `name` in the loaded RBS environment. Callers MUST treat the return value as authoritative
@@ -2904,15 +2925,16 @@ module Rigor
2904
2925
  #
2905
2926
  # So the rescue only remembers, keyed by the `::`-stripped name (the eager walk spells it `::Acme`,
2906
2927
  # a dispatch site spells it `Acme`, and they are one class — the {#synthesized_namespaces}
2907
- # convention), and {#report_definition_build_failure} decides. First writer wins: the instance and
2908
- # singleton sides fail for the same underlying collision, and the first is the one a caller hit.
2928
+ # convention), and {#report_definition_build_failure} decides. Every side's detail is kept, not the
2929
+ # first one written (issue #1902): the instance and singleton sides can fail for DIFFERENT reasons
2930
+ # (a duplicated `def self.s` and a duplicated `def i` on one class), and which side a run demands first
2931
+ # depends on which files it analysed, so first-writer-wins made the reported tuple — and with it the
2932
+ # message's "First failure", advice and file list — differ between a cold run and a warm recheck.
2909
2933
  def store_definition_build_detail(class_name, error)
2910
2934
  key = class_name.to_s.delete_prefix("::")
2911
- details = (@state[:definition_build_details] ||= {})
2912
- return if details.key?(key)
2913
-
2914
- details[key] = [key, error.class.name.to_s, definition_build_member(error),
2915
- definition_build_conflict_buffers(error), unresolved_type_name_detail(error)].freeze
2935
+ details = ((@state[:definition_build_details] ||= {})[key] ||= [])
2936
+ details << [key, error.class.name.to_s, definition_build_member(error),
2937
+ definition_build_conflict_buffers(error), unresolved_type_name_detail(error)].freeze
2916
2938
  end
2917
2939
 
2918
2940
  # Issue #997 — the `RBS::NoTypeFoundError` twin of {#definition_build_member}: `nil` for every other
@@ -2987,14 +3009,28 @@ module Rigor
2987
3009
  return if details.nil?
2988
3010
 
2989
3011
  key = class_name.to_s.delete_prefix("::")
2990
- detail = details[key]
2991
- return if detail.nil?
3012
+ return if details[key].nil?
2992
3013
 
2993
3014
  reported = (@state[:definition_build_reported] ||= {})
2994
3015
  return if reported[key]
2995
3016
 
2996
3017
  reported[key] = true
2997
- (@state[:definition_build_failures] ||= []) << detail
3018
+ attempt_both_definition_sides(class_name)
3019
+ canonical = details[key].min_by { |candidate| self.class.definition_build_detail_order(candidate) }
3020
+ (@state[:definition_build_failures] ||= []) << canonical
3021
+ end
3022
+
3023
+ # Issue #1902 — a reported class's tuple is chosen from BOTH sides' failures, so the side this demand
3024
+ # did not ask for is built here too. Without it, the candidate set would be whatever sides the run
3025
+ # happened to demand, and a recheck whose closure touches only `Berry.s` would report the singleton
3026
+ # side's reason where the full run, which also demanded `Berry.new.i`, reported the instance side's.
3027
+ # An internal demand: it decides WHICH reason is shown for a class the analysis already demanded, never
3028
+ # WHETHER a class is reported, so the set #696 binds to the analysis's own demand is unchanged.
3029
+ def attempt_both_definition_sides(class_name)
3030
+ during_internal_demand do
3031
+ instance_definition(class_name)
3032
+ singleton_definition(class_name)
3033
+ end
2998
3034
  end
2999
3035
 
3000
3036
  # Marks a definition demand that is RIGOR'S OWN, not the analysed program's, so
@@ -165,7 +165,12 @@ module Rigor
165
165
  # nothing for names this rare, and the block-return pass is no independent check here: it types a
166
166
  # self-call to an overridden `raise` as Kernel's `bot` too, so a missed override is a wrong `bot`.
167
167
  # {LastLine.reads_line?} declines on it the same way for `gets` and `readline`.
168
+ #
169
+ # Issue #1459 — the answer reads every project file's tables whole, so it records a `defines:<name>` edge
170
+ # whichever way it answers: an edit that makes a file start or stop defining the name re-checks the reader
171
+ # (`Incremental.changed_defined_names`). A `pre_eval:` patch moves the snapshot's own fingerprint.
168
172
  def project_defines_anywhere?(method_name, scope)
173
+ Analysis::DependencyRecorder.read_missing(:defines, method_name) if Analysis::DependencyRecorder.active?
169
174
  return true if scope.top_level_def_for(method_name)
170
175
  # `discovered_methods` withholds a plain cross-file `def` (the ADR-17 monkey-patch contract); the
171
176
  # def-node tables still carry it, on both sides.
@@ -9,6 +9,7 @@ require_relative "index_write_widening"
9
9
  require_relative "mutation_widening"
10
10
  require_relative "optimistic_origin"
11
11
  require_relative "receiver_alias"
12
+ require_relative "reflective_ivar_write"
12
13
  require_relative "unknown_store_widening"
13
14
 
14
15
  module Rigor
@@ -129,6 +130,9 @@ module Rigor
129
130
  if LOCAL_WRITE_NODES.include?(descendant.class)
130
131
  captured_local_write(descendant, ancestors, base_scope) { introduced ||= introduced_locals(block_node) }
131
132
  elsif outliving
133
+ if ReflectiveIvarWrite.unknown_target?(descendant)
134
+ (names ||= []).concat(reflective_unknown_ivars(base_scope))
135
+ end
132
136
  rebindable_outliving_write(descendant, base_scope, non_locals)
133
137
  end
134
138
  (names ||= []) << name if name
@@ -136,6 +140,13 @@ module Rigor
136
140
  names ? names.uniq : NO_NAMES
137
141
  end
138
142
 
143
+ # Issue #1889 — every ivar `base_scope` binds: an `instance_variable_set` on `self` whose target no literal
144
+ # names ({ReflectiveIvarWrite.unknown_target?}) may write any of them. One on its ADR-58 declaration seed
145
+ # counts too, since the census behind the seed sees no reflective write; {.bind} keeps its mark.
146
+ def reflective_unknown_ivars(base_scope)
147
+ base_scope.ivars.keys
148
+ end
149
+
139
150
  def rebindable_outliving_write(node, base_scope, non_locals)
140
151
  name = outliving_write_name(node, non_locals)
141
152
  name if name && rebindable_non_local?(base_scope, name)
@@ -151,8 +162,12 @@ module Rigor
151
162
  yield.include?(name) ? nil : name
152
163
  end
153
164
 
154
- # The non-local `node` rebinds under `non_locals:` — or, without it, the instance variable only.
165
+ # The non-local `node` rebinds under `non_locals:` — or, without it, the instance variable only. A literal-name
166
+ # `instance_variable_set` on `self` ({ReflectiveIvarWrite.literal_name}) is a write of that ivar under either.
155
167
  def outliving_write_name(node, non_locals)
168
+ reflective = ReflectiveIvarWrite.literal_name(node)
169
+ return reflective if reflective
170
+
156
171
  name = non_local_write_name(node)
157
172
  non_locals || (name && ivar_name?(name) && NON_LOCAL_WRITE_NODES.include?(node.class)) ? name : nil
158
173
  end
@@ -201,13 +216,21 @@ module Rigor
201
216
  # it the miss answer the mark records (issue #1302).
202
217
  def bind(scope, name, type, optimistic: nil)
203
218
  case variable_kind(name)
204
- when :ivar then OptimisticOrigin.carry_ivar_mark(scope, scope.with_ivar(name, type), name, optimistic)
219
+ when :ivar then bind_ivar(scope, name, type, optimistic)
205
220
  when :cvar then scope.with_cvar(name, type)
206
221
  when :global then scope.with_global(name, type)
207
222
  else OptimisticOrigin.carry_local_mark(scope, scope.with_local(name, type), name, optimistic)
208
223
  end
209
224
  end
210
225
 
226
+ # An ivar on its ADR-58 declaration seed reaches {.bind} through {.reflective_unknown_ivars}, and through
227
+ # `ExpressionTyper#prefix_answered_scope`; rebinding it re-stamps the declaration mark, as
228
+ # `ReflectiveIvarWrite.apply` does, so a nil in the seed fuels no nil-receiver diagnostic.
229
+ def bind_ivar(scope, name, type, optimistic)
230
+ bound = OptimisticOrigin.carry_ivar_mark(scope, scope.with_ivar(name, type), name, optimistic)
231
+ scope.declaration_sourced?(:ivar, name) ? bound.seed_declaration_sourced_ivar(name, type) : bound
232
+ end
233
+
211
234
  # The optimistic nil-freeness mark `scope` holds for a name from {.writes}, or nil.
212
235
  def optimistic_mark(scope, name)
213
236
  case variable_kind(name)
@@ -290,7 +313,7 @@ module Rigor
290
313
  receiver = node.receiver
291
314
  return true if receiver && MutationWidening::SHAPE_MUTATORS.include?(node.name)
292
315
 
293
- callee_call?(node)
316
+ ReflectiveIvarWrite.self_write?(node) || callee_call?(node)
294
317
  end
295
318
 
296
319
  # Issue #1412 — {.content_mutations} for a `while` / `until` / `for` body: the locals bound in `base_scope`
@@ -39,6 +39,9 @@ module Rigor
39
39
  Hit = Data.define(:answer, :owner, :index, :side)
40
40
 
41
41
  QUESTIONS = %i[definer visibility arity override own_side].freeze
42
+ # The questions whose answer reads a recorded visibility: only they treat a contested visibility slot as a
43
+ # possible definer ({#possible?}).
44
+ VISIBILITY_QUESTIONS = %i[visibility override].freeze
42
45
  NONE = :rigor_definer_resolution_none
43
46
  STRAY = :rigor_definer_resolution_stray
44
47
  private_constant :NONE, :STRAY
@@ -58,12 +61,13 @@ module Rigor
58
61
  flavor = question == :arity ? :arity : :methods
59
62
  chain = Scope::ResolutionChain.for(scope, class_name.to_s, side, flavor)
60
63
 
61
- hits = candidates(scope, chain, method_name, from, answer_in)
64
+ reads_visibility = VISIBILITY_QUESTIONS.include?(question)
65
+ hits = candidates(scope, chain, method_name, from, answer_in, reads_visibility)
62
66
  return UNKNOWN if hits.nil?
63
67
 
64
68
  verdict = chain.settle(scope, outcomes(hits), owner: hits.first&.owner, unknown_for: method_name) do |retro|
65
69
  retro_from = retro_position(chain, retro, from)
66
- outcomes(retro_from && candidates(scope, retro, method_name, retro_from, answer_in))
70
+ outcomes(retro_from && candidates(scope, retro, method_name, retro_from, answer_in, reads_visibility))
67
71
  end
68
72
  return UNKNOWN unless verdict == :chain
69
73
  return UNKNOWN if side == :singleton && SingletonHookDecline.decline?(scope, chain, method_name, from, hits)
@@ -101,7 +105,7 @@ module Rigor
101
105
  # follows (the absent candidate). A cut chain, and a chain on which an external ancestor ahead of the
102
106
  # candidate (or, with no candidate, the implicit `Object`) may define the name, is a decline, answered by
103
107
  # nil instead of a set.
104
- def candidates(scope, chain, method_name, from, answer_in)
108
+ def candidates(scope, chain, method_name, from, answer_in, reads_visibility)
105
109
  hits = []
106
110
  position = from
107
111
  loop do
@@ -113,7 +117,9 @@ module Rigor
113
117
  return nil if hit.nil? && chain.truncated?
114
118
 
115
119
  hits << hit
116
- return decided(scope, chain, method_name, from, hits) if hit.nil? || !possible?(scope, hit, method_name)
120
+ if hit.nil? || !possible?(scope, hit, method_name, reads_visibility)
121
+ return decided(scope, chain, method_name, from, hits)
122
+ end
117
123
 
118
124
  position = hit.index + 1
119
125
  end
@@ -188,15 +194,18 @@ module Rigor
188
194
  end
189
195
 
190
196
  # A definer that rests on a fact the walk could not prove executes: the member's `possible` sibling, or a
191
- # contested slot of one of the single-valued members.
192
- def possible?(scope, hit, method_name)
197
+ # contested slot of one of the single-valued members. A contested visibility slot counts only for a question
198
+ # that reads the visibility (`reads_visibility`): a slot is contested there for a visibility call the walk
199
+ # cannot place (an uncertain toggle, `private(*names)`, `private def self.x`), which leaves the definer and its
200
+ # `def` certain; a possible `def` or `attr_*` call contests its def-node slot or its name's `possible` side too.
201
+ def possible?(scope, hit, method_name, reads_visibility)
193
202
  discovery = scope.discovery
194
203
  owner = hit.owner
195
204
  kind = hit.side == :singleton ? :singleton : :instance
196
205
  nodes = kind == :singleton ? :discovered_singleton_def_nodes : :discovered_def_nodes
197
206
  discovery.possible_method?(owner, method_name, kind) ||
198
207
  discovery.contested?(nodes, [owner, method_name.to_sym]) ||
199
- discovery.contested?(:discovered_method_visibilities, [owner, method_name.to_sym]) ||
208
+ (reads_visibility && discovery.contested?(:discovered_method_visibilities, [owner, method_name.to_sym])) ||
200
209
  discovery.contested?(:discovered_parameter_envelopes, [owner, [kind, method_name.to_sym]])
201
210
  end
202
211