rigortype 0.3.4 → 0.3.5

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/effects/core.yml +18 -1
  4. data/data/effects/registry.yml +31 -3
  5. data/docs/manual/02-cli-reference.md +60 -13
  6. data/docs/manual/03-configuration.md +20 -3
  7. data/docs/manual/04-diagnostics.md +3 -2
  8. data/docs/manual/11-ci.md +37 -0
  9. data/docs/manual/12-caching.md +39 -0
  10. data/docs/manual/16-rbs-extended-annotations.md +15 -2
  11. data/docs/manual/19-effect-labels.md +671 -0
  12. data/docs/manual/README.md +5 -0
  13. data/docs/manual/ci-templates/README.md +9 -0
  14. data/lib/rigor/analysis/rule_catalog.rb +10 -3
  15. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  16. data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
  17. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
  18. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
  19. data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
  20. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  21. data/lib/rigor/analysis/runner.rb +12 -3
  22. data/lib/rigor/analysis/worker_session.rb +3 -1
  23. data/lib/rigor/cli/effects_command.rb +123 -9
  24. data/lib/rigor/cli/effects_diff_renderer.rb +5 -1
  25. data/lib/rigor/cli/effects_renderer.rb +41 -3
  26. data/lib/rigor/cli/effects_report.rb +116 -6
  27. data/lib/rigor/cli/effects_snapshot_command.rb +23 -4
  28. data/lib/rigor/cli.rb +12 -1
  29. data/lib/rigor/configuration.rb +37 -25
  30. data/lib/rigor/configuration_error.rb +20 -0
  31. data/lib/rigor/effects/collector.rb +38 -1
  32. data/lib/rigor/effects/entry_points.rb +47 -1
  33. data/lib/rigor/effects/file_collection.rb +18 -4
  34. data/lib/rigor/effects/framework_units.rb +68 -13
  35. data/lib/rigor/effects/inline_anchor.rb +134 -0
  36. data/lib/rigor/effects/plugin_facts.rb +62 -13
  37. data/lib/rigor/effects/propagator.rb +79 -19
  38. data/lib/rigor/effects/registry.rb +10 -3
  39. data/lib/rigor/effects/scanner.rb +21 -9
  40. data/lib/rigor/effects/signature_sources.rb +16 -0
  41. data/lib/rigor/effects/snapshot.rb +21 -5
  42. data/lib/rigor/effects/taint_cause.rb +1 -0
  43. data/lib/rigor/effects/unit_scan.rb +87 -12
  44. data/lib/rigor/plugin/base.rb +4 -0
  45. data/lib/rigor/plugin/box.rb +18 -2
  46. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  47. data/lib/rigor/plugin/manifest.rb +34 -10
  48. data/lib/rigor/plugin/registry.rb +10 -3
  49. data/lib/rigor/rbs_extended.rb +22 -2
  50. data/lib/rigor/version.rb +1 -1
  51. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
  52. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  53. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  54. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  55. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
  56. metadata +6 -1
@@ -16,17 +16,53 @@ module Rigor
16
16
  class EffectsRenderer
17
17
  include Renderable
18
18
 
19
- def initialize(out:)
19
+ def initialize(out:, why: false)
20
20
  @out = out
21
+ @why = why
21
22
  end
22
23
 
23
24
  private
24
25
 
26
+ # The reason block is **collapsed to a count** by default (#434). It was 86.5 % of the bytes of a
27
+ # 31,191-line Redmine run, and it answers a question a reader asks about one row after reading many
28
+ # — so `--why` expands it, and the count is what stays on the line that made them curious.
25
29
  def render_text(report)
26
30
  report.rows.each do |row|
27
- @out.puts("#{row.key}: [#{row.effects.join(', ')}]#{declared(row)}#{' …?' unless row.exhaustive?}")
31
+ @out.puts("#{row.key}: [#{row.effects.join(', ')}]#{declared(row)}#{hedge(row)}")
32
+ next unless @why
33
+
28
34
  row.causes.each { |cause, detail| @out.puts(" #{cause}#{" (#{detail})" if detail}") }
35
+ row.attribution.each { |origin, labels| @out.puts(" #{origin} → [#{labels.join(', ')}]") }
29
36
  end
37
+ render_footer(report.totals)
38
+ end
39
+
40
+ def hedge(row)
41
+ return "" if row.exhaustive?
42
+ return " …?" if @why || row.causes.empty?
43
+
44
+ " …? (#{row.causes.length} #{row.causes.length == 1 ? 'reason' : 'reasons'}, --why)"
45
+ end
46
+
47
+ # The footer a 31,191-line report never had, and the reason it counts the two lanes apart: a
48
+ # declared label can never fail a build (ADR-103 § WD17), so a reader who sees one total cannot tell
49
+ # which half of the report is a policy surface and which half is a record to review the diff of.
50
+ def render_footer(totals)
51
+ return if totals.nil?
52
+
53
+ @out.puts("──")
54
+ @out.puts("#{totals.printed} of #{totals.units} units printed#{omitted(totals)}")
55
+ @out.puts("#{totals.proven} carry a proven label · #{totals.declared} carry a declared (≤) one " \
56
+ "· #{totals.exhaustive} are exhaustive")
57
+ end
58
+
59
+ # Two ways a row can be missing, counted apart because `--full` answers only one of them.
60
+ def omitted(totals)
61
+ parts = []
62
+ parts << "#{totals.omitted} omitted (--full)" if totals.omitted.positive?
63
+ parts << "#{totals.unselected} not selected" if totals.unselected.positive?
64
+ parts << "#{totals.truncated} cut by --limit" if totals.truncated.positive?
65
+ parts.empty? ? "" : "; #{parts.join(', ')}"
30
66
  end
31
67
 
32
68
  # `≤` is the lane's spelling everywhere in the model — an upper bound, not an observation — so the
@@ -43,10 +79,12 @@ module Rigor
43
79
  "declared" => row.declared,
44
80
  "exhaustive" => row.exhaustive?,
45
81
  "causes" => row.causes.map { |cause, detail| [cause, detail] },
46
- "direct" => row.direct
82
+ "direct" => row.direct,
83
+ "attribution" => row.attribution
47
84
  }]
48
85
  end
49
86
  }
87
+ payload["totals"] = report.totals.to_h.transform_keys(&:to_s) if report.totals
50
88
  @out.puts(JSON.pretty_generate(payload))
51
89
  end
52
90
  end
@@ -7,7 +7,12 @@ module Rigor
7
7
  #
8
8
  # Rows are sorted by key and every collection inside one is sorted, so two runs over the same tree
9
9
  # print byte-identical output whether analysis ran sequentially or across the fork pool.
10
- class EffectsReport < Data.define(:rows, :full)
10
+ class EffectsReport < Data.define(:rows, :full, :totals)
11
+ # `totals:` defaults so a caller that builds a report by hand keeps working.
12
+ def initialize(totals: nil, **rest)
13
+ super
14
+ end
15
+
11
16
  # One method's line in the report.
12
17
  #
13
18
  # `effects` is the transitive proven lane — this method's labels joined with every project method it
@@ -20,18 +25,116 @@ module Rigor
20
25
  # is printed apart from `effects` and never folded into it, because the two answer different
21
26
  # questions: one is proven, the other is asserted. A declared label the proven lane already admits
22
27
  # is dropped here, where output is rendered; the table keeps both lanes raw.
23
- class Row < Data.define(:key, :effects, :declared, :exhaustive, :causes, :direct)
28
+ class Row < Data.define(:key, :effects, :declared, :exhaustive, :causes, :direct, :attribution)
29
+ # `attribution:` defaults so a caller that builds a row by hand keeps working.
30
+ def initialize(attribution: {}, **rest)
31
+ super
32
+ end
33
+
24
34
  def exhaustive?
25
35
  exhaustive
26
36
  end
37
+
38
+ # The reading `--pure` selects and the default report omits: nothing proven beyond what every
39
+ # envelope tolerates, nothing claimed, and no "possibly more".
40
+ def pure?
41
+ exhaustive && declared.empty? && (effects - TRIVIAL).empty?
42
+ end
43
+
44
+ # `mutate.local` is mutation of objects the frame allocated and never let out, which every
45
+ # envelope tolerates — so a method proving only it is what `%a{pure}` means here.
46
+ TRIVIAL = ["mutate.local"].freeze
47
+ private_constant :TRIVIAL
48
+
49
+ def carries_label?(labels)
50
+ labels.any? { |label| (effects + declared).any? { |own| own == label || own.start_with?("#{label}.") } }
51
+ end
27
52
  end
28
53
 
54
+ # The counts the footer prints and the JSON payload carries (#434). The two lanes are counted
55
+ # **separately** and deliberately: a declared label can never fail a build ([ADR-103](../adr/103-effect-labels.md)
56
+ # § WD17), and on a Rails application it is most of the mass — 709 `io.db.read` plus 644
57
+ # `io.db.write` on Redmine's 4,234 rows against zero of either proven. A single total tells a reader
58
+ # how big the report is; the split tells them which half is a policy surface and which half is a
59
+ # record whose diff they should be reviewing instead.
60
+ Totals = Data.define(:units, :printed, :omitted, :unselected, :proven, :declared, :exhaustive,
61
+ :truncated)
62
+
29
63
  # Builds a report from an effect table. `full:` keeps the rows the report otherwise omits — an
30
64
  # exhaustive method proving nothing beyond `mutate.local`, which is the reading of `%a{pure}`.
31
- def self.build(table, full: false)
32
- rows = table.filter_map { |entry| row_for(entry) unless !full && entry.trivial? }
33
- new(rows: rows.freeze, full: full)
65
+ #
66
+ # `scope:` selects which units are **printed** and never which are analysed (#439). A path argument
67
+ # used to narrow the analysis, and a summary is transitive over whatever was analysed, so the
68
+ # narrowed run answered `[] …?` for a method the whole-project run answered four labels for — with
69
+ # nothing distinguishing that from a method which genuinely does nothing. `sources:` is
70
+ # `Runner#effect_sources`, `{ "Class#m" => [path, …] }`, which is how a key is traced back to the
71
+ # file it was written in.
72
+ def self.build(table, full: false, sources: nil, scope: [], label: [], pure: false, limit: nil)
73
+ roots = normalize_scope(scope)
74
+ in_scope = table.filter_map do |entry|
75
+ row_for(entry) if roots.empty? || in_scope?(entry.key, sources, roots)
76
+ end
77
+ selected = in_scope.select { |row| keep?(row, full: full, label: label, pure: pure) }
78
+ new(rows: (limit ? selected.first(limit) : selected).freeze, full: full,
79
+ totals: totals_for(table, in_scope, selected, limit, query: pure || !label.empty?))
80
+ end
81
+
82
+ # What the default report drops, and why each is a separate question:
83
+ #
84
+ # - **`full:`** keeps a row the omission rule would drop. Two shapes qualify: a method proving
85
+ # nothing beyond `mutate.local` and claiming nothing (the reading of `%a{pure}`), and a row with
86
+ # no label in **either** lane, which is 35–38 % of a real application's rows and says literally
87
+ # nothing — it exists only to record that something was unresolved, which the footer counts.
88
+ # - **`pure:`** is the complement of the first: exactly the rows the default omits for being
89
+ # provably harmless, which is the set worth annotating and which nothing could ask for (#457).
90
+ # - **`label:`** is the question chapter 19 opens with. It matches **either** lane, because "which
91
+ # controllers reach the network" is a question about the code and not about which lane knows it;
92
+ # the row's own rendering keeps the two apart.
93
+ def self.keep?(row, full:, label:, pure:)
94
+ return row.pure? if pure
95
+ return row.carries_label?(label) unless label.empty?
96
+ return true if full
97
+
98
+ !row.pure? && !(row.effects.empty? && row.declared.empty?)
99
+ end
100
+ private_class_method :keep?
101
+
102
+ # Two ways a row can be missing, and only one of them `--full` answers: the **omission rule** drops
103
+ # a row that says nothing, and a **filter** — a path, `--label`, `--pure` — drops one that did not
104
+ # match. Telling a `--label io.net` reader that 4,678 more are behind `--full` would be false, so
105
+ # the two are counted apart.
106
+ def self.totals_for(table, in_scope, selected, limit, query:)
107
+ omitted = query ? 0 : in_scope.length - selected.length
108
+ Totals.new(
109
+ units: table.size, printed: limit ? [selected.length, limit].min : selected.length,
110
+ omitted: omitted, unselected: table.size - selected.length - omitted,
111
+ proven: selected.count { |row| !row.effects.empty? },
112
+ declared: selected.count { |row| !row.declared.empty? },
113
+ exhaustive: selected.count(&:exhaustive?),
114
+ truncated: limit ? [selected.length - limit, 0].max : 0
115
+ )
116
+ end
117
+ private_class_method :totals_for
118
+
119
+ # A path argument may name a file or a directory, and either may be written relative or absolute —
120
+ # so both sides are expanded and a directory matches by prefix. Deliberately not the `reach:` glob
121
+ # syntax: this is the argument a shell just tab-completed, and `rigor effects app/models` meaning
122
+ # "that directory" is the only reading a reader would guess.
123
+ def self.normalize_scope(scope)
124
+ Array(scope).map { |path| File.expand_path(path.to_s.chomp("/")) }.freeze
125
+ end
126
+ private_class_method :normalize_scope
127
+
128
+ def self.in_scope?(key, sources, roots)
129
+ paths = sources && sources[key]
130
+ return false if paths.nil? || paths.empty?
131
+
132
+ paths.any? do |path|
133
+ absolute = File.expand_path(path)
134
+ roots.any? { |root| absolute == root || absolute.start_with?("#{root}/") }
135
+ end
34
136
  end
137
+ private_class_method :in_scope?
35
138
 
36
139
  def self.row_for(entry)
37
140
  Row.new(
@@ -40,7 +143,14 @@ module Rigor
40
143
  declared: entry.rendered_declared.to_a,
41
144
  exhaustive: entry.exhaustive?,
42
145
  causes: entry.causes,
43
- direct: entry.direct.bundles.to_h { |origin, labels| [origin.to_s, labels.to_a] }.freeze
146
+ direct: entry.direct.bundles.to_h { |origin, labels| [origin.to_s, labels.to_a] }.freeze,
147
+ # The declared lane's provenance (#434). A *discharging* plugin row leaves no cause line by
148
+ # design — WD6: a row the engine bundles is trusted, so it does not taint — which is why
149
+ # `plugin-attribution` appeared zero times in a whole Redmine run while 1,320 rows carried a
150
+ # `≤` clause. The origins are where the answer actually lives, and this is the direct half; a
151
+ # label that arrived transitively is `rigor effects explain`'s question.
152
+ attribution: entry.direct.declared_bundles.to_h { |origin, labels| [origin.to_s, labels.to_a] }
153
+ .freeze
44
154
  )
45
155
  end
46
156
  private_class_method :row_for
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "did_you_mean"
3
4
  require "optionparser"
4
5
 
5
6
  require_relative "../analysis/runner"
6
7
  require_relative "../cache/store"
7
8
  require_relative "../configuration"
8
9
  require_relative "../effects/discharge"
10
+ require_relative "../effects/entry_points"
9
11
  require_relative "../effects/path_finder"
10
12
  require_relative "../effects/snapshot"
11
13
  require_relative "../effects/snapshot_diff"
@@ -79,8 +81,14 @@ module Rigor
79
81
  File.write(path, snapshot.to_yaml)
80
82
  @err.puts("rigor: wrote #{path} (#{snapshot.methods.size} method(s), #{snapshot.reach.size} reach entr" \
81
83
  "#{snapshot.reach.size == 1 ? 'y' : 'ies'})")
82
- @err.puts("rigor: note`effects.snapshot.reach:` is empty, so the snapshot records `methods:` only.") if
83
- configuration.effects_snapshot_reach.empty?
84
+ # #436the note names what to write, not only what is missing: the same per-project preset
85
+ # enumeration the unregistered-preset error uses, so the two can never disagree about which
86
+ # names this project's plugin set makes available.
87
+ if configuration.effects_snapshot_reach.empty?
88
+ @err.puts("rigor: note — `effects.snapshot.reach:` is empty, so the snapshot records `methods:` " \
89
+ "only — the direct half. Name your entry points to record what they cause: " \
90
+ "#{Effects::EntryPoints.availability}. The written file carries the same hint.")
91
+ end
84
92
  0
85
93
  end
86
94
 
@@ -126,20 +134,31 @@ module Rigor
126
134
 
127
135
  def explain(diff, options)
128
136
  rows = options.fetch(:symbol) ? rows_for_symbol(options.fetch(:symbol)) : rows_for_changes(diff)
137
+ return CLI::EXIT_USAGE if rows == :unknown
138
+
129
139
  EffectsExplainRenderer.new(out: @out).render(rows, format: options.fetch(:format))
130
140
  0
131
141
  end
132
142
 
143
+ # A misspelled `--symbol` used to print `Nothing to explain.` and exit 0, which is exactly what a
144
+ # method with no effects prints — so a typo and a real answer were indistinguishable, and a script
145
+ # could not tell them apart at all (#435). It is a usage error now, with the nearest key offered:
146
+ # the key set is right there, and a method key is long enough to get wrong.
133
147
  def rows_for_symbol(symbol)
134
148
  entry = @table[symbol]
135
149
  if entry.nil?
136
- @err.puts("rigor: no effect unit named #{symbol}")
137
- return []
150
+ @err.puts("rigor: no effect unit named #{symbol}#{suggestion(symbol)}")
151
+ return :unknown
138
152
  end
139
153
 
140
154
  reach_rows(symbol, entry.proven.to_a) + method_rows(symbol, entry.direct.proven.to_a)
141
155
  end
142
156
 
157
+ def suggestion(symbol)
158
+ nearest = ::DidYouMean::SpellChecker.new(dictionary: @table.keys).correct(symbol).first
159
+ nearest ? " — did you mean #{nearest}?" : ""
160
+ end
161
+
143
162
  # Every label a change introduced, explained once: a `reach:` change gets its shortest edge path, a
144
163
  # `methods:` change gets the origin in the method's own body.
145
164
  def rows_for_changes(diff)
data/lib/rigor/cli.rb CHANGED
@@ -84,6 +84,17 @@ module Rigor
84
84
  rescue OptionParser::ParseError => e
85
85
  @err.puts(e.message)
86
86
  EXIT_USAGE
87
+ rescue ConfigurationError => e
88
+ # #433 — a mistake in `.rigor.yml` is the same kind of event as a bad flag, and belongs in the same
89
+ # shape: one `rigor:` line naming the key, and the conventional usage exit code. It used to escape
90
+ # as an uncaught exception with a ~30-frame backtrace naming a file inside `lib/rigor/`, which reads
91
+ # as a crash even though the message it carried said exactly which key to fix.
92
+ #
93
+ # Caught here rather than per command because every command loads a configuration, and the ones
94
+ # that resolve a key later (`effects update` expanding `snapshot.reach:` once the plugins that
95
+ # register presets have loaded) would each need their own rescue at their own point.
96
+ @err.puts("rigor: #{e.message}")
97
+ EXIT_USAGE
87
98
  end
88
99
 
89
100
  private
@@ -160,7 +171,7 @@ module Rigor
160
171
  # - paths: directories scanned by `rigor check` and
161
172
  # `rigor type-scan` when no path is given.
162
173
  # - plugins: opt-in list of plugin gem names to load.
163
- # See https://github.com/rigortype/rigor/tree/main/plugins
174
+ # See https://github.com/rigortype/rigor/tree/master/plugins
164
175
  # for production plugins (rigor-activerecord, rigor-sorbet, …).
165
176
  # - disable: list of `rigor check` rule identifiers to
166
177
  # silence project-wide. The shipped rules are
@@ -6,6 +6,7 @@ require_relative "bleeding_edge"
6
6
  require_relative "ci_detector"
7
7
  require_relative "configuration/dependencies"
8
8
  require_relative "configuration/severity_profile"
9
+ require_relative "configuration_error"
9
10
  require_relative "effects/entry_points"
10
11
  require_relative "effects/label"
11
12
  require_relative "effects/method_key"
@@ -358,10 +359,10 @@ module Rigor
358
359
  # {CLI::CheckCommand#load_check_configuration}).
359
360
  def self.load_with_includes(path, visited: Set.new)
360
361
  absolute = File.expand_path(path)
361
- raise ArgumentError, "circular include: #{absolute}" if visited.include?(absolute)
362
+ raise ConfigurationError, "circular include: #{absolute}" if visited.include?(absolute)
362
363
 
363
- raw = YAML.safe_load_file(absolute, aliases: false) || {}
364
- raise ArgumentError, "config file must be a YAML mapping: #{absolute}" unless raw.is_a?(Hash)
364
+ raw = read_yaml(absolute)
365
+ raise ConfigurationError, "config file must be a YAML mapping: #{absolute}" unless raw.is_a?(Hash)
365
366
 
366
367
  base_dir = File.dirname(absolute)
367
368
  includes = Array(raw.delete("includes") || [])
@@ -370,6 +371,17 @@ module Rigor
370
371
  merge_includes(data, includes, base_dir, next_visited)
371
372
  end
372
373
 
374
+ # #433's sibling: a typo in the file the user is about to be told to fix is a configuration mistake
375
+ # like any other, and escaped as a `Psych::SyntaxError` backtrace naming a file inside Ruby's stdlib.
376
+ # Psych's own `#message` embeds the path in a parenthesised prefix that reads badly after `rigor: `,
377
+ # so the position is re-rendered in the `path:line:column` form the rest of Rigor's output uses.
378
+ def self.read_yaml(absolute)
379
+ YAML.safe_load_file(absolute, aliases: false) || {}
380
+ rescue Psych::SyntaxError => e
381
+ detail = [e.problem, e.context].compact.join(" ")
382
+ raise ConfigurationError, "#{absolute}:#{e.line}:#{e.column}: not valid YAML: #{detail}"
383
+ end
384
+
373
385
  def self.merge_includes(data, includes, base_dir, visited)
374
386
  return data if includes.empty?
375
387
 
@@ -377,7 +389,7 @@ module Rigor
377
389
  includes.each do |inc|
378
390
  inc_path = File.expand_path(inc.to_s, base_dir)
379
391
  unless File.exist?(inc_path)
380
- raise ArgumentError, "include not found: #{inc.inspect} (referenced from #{base_dir})"
392
+ raise ConfigurationError, "include not found: #{inc.inspect} (referenced from #{base_dir})"
381
393
  end
382
394
 
383
395
  accumulated = deep_merge(accumulated, load_with_includes(inc_path, visited: visited))
@@ -737,7 +749,7 @@ module Rigor
737
749
 
738
750
  gate = value.to_s.to_sym
739
751
  unless VALID_EFFECTS_GATES.include?(gate)
740
- raise ArgumentError,
752
+ raise ConfigurationError,
741
753
  "effects.snapshot.gate must be one of #{VALID_EFFECTS_GATES.inspect}, got #{value.inspect}"
742
754
  end
743
755
 
@@ -756,7 +768,7 @@ module Rigor
756
768
  entries.each do |entry|
757
769
  next if Effects::EntryPoints.glob?(entry) || Effects::EntryPoints.name?(entry)
758
770
 
759
- raise ArgumentError,
771
+ raise ConfigurationError,
760
772
  "effects.snapshot.reach entry is neither a file glob nor a well-formed entry-point preset " \
761
773
  "name: #{entry.inspect} (a preset name is #{Effects::EntryPoints::NAME_PATTERN.inspect}; " \
762
774
  "anything carrying a path or glob character is treated as a file glob instead)"
@@ -768,7 +780,7 @@ module Rigor
768
780
  def coerce_effects_tolerated(value)
769
781
  labels = Array(value).map(&:to_s)
770
782
  labels.each do |label|
771
- raise ArgumentError, "effects.tolerated is not a well-formed effect label: #{label.inspect}" unless
783
+ raise ConfigurationError, "effects.tolerated is not a well-formed effect label: #{label.inspect}" unless
772
784
  Effects::Label.valid?(label)
773
785
  end
774
786
  labels.uniq.sort.freeze
@@ -795,7 +807,7 @@ module Rigor
795
807
  def coerce_effects_labels(value)
796
808
  labels = Array(value).map(&:to_s)
797
809
  labels.each do |label|
798
- raise ArgumentError, "effects.labels is not a well-formed effect label: #{label.inspect}" unless
810
+ raise ConfigurationError, "effects.labels is not a well-formed effect label: #{label.inspect}" unless
799
811
  Effects::Label.valid?(label)
800
812
  end
801
813
  labels.uniq.sort.freeze
@@ -809,7 +821,7 @@ module Rigor
809
821
  value.each_with_object({}) do |(key, labels), out|
810
822
  name = key.to_s
811
823
  unless Effects::MethodKey.valid?(name)
812
- raise ArgumentError,
824
+ raise ConfigurationError,
813
825
  "effects.attribution key is not a method key (`Owner#method` / `Owner.method`): #{name.inspect}"
814
826
  end
815
827
 
@@ -826,16 +838,16 @@ module Rigor
826
838
 
827
839
  def coerce_effects_envelope(entry, index)
828
840
  where = "effects.envelopes[#{index}]"
829
- raise ArgumentError, "#{where} is not a mapping: #{entry.inspect}" unless entry.is_a?(Hash)
841
+ raise ConfigurationError, "#{where} is not a mapping: #{entry.inspect}" unless entry.is_a?(Hash)
830
842
 
831
843
  match = coerce_effects_envelope_selector(entry["match"], "#{where}.match")
832
844
  namespace = coerce_effects_envelope_selector(entry["namespace"], "#{where}.namespace")
833
845
  if match.nil? == namespace.nil?
834
- raise ArgumentError, "#{where} must name exactly one of `match:` (a path glob) or `namespace:` " \
835
- "(a constant glob), got #{match.nil? ? 'neither' : 'both'}"
846
+ raise ConfigurationError, "#{where} must name exactly one of `match:` (a path glob) or `namespace:` " \
847
+ "(a constant glob), got #{match.nil? ? 'neither' : 'both'}"
836
848
  end
837
849
  unless entry.key?("effect")
838
- raise ArgumentError, "#{where} has no `effect:` bound (write `effect: []` for the empty envelope)"
850
+ raise ConfigurationError, "#{where} has no `effect:` bound (write `effect: []` for the empty envelope)"
839
851
  end
840
852
 
841
853
  {
@@ -848,14 +860,14 @@ module Rigor
848
860
  return nil if value.nil?
849
861
 
850
862
  selector = value.to_s
851
- raise ArgumentError, "#{where} is empty" if selector.strip.empty?
863
+ raise ConfigurationError, "#{where} is empty" if selector.strip.empty?
852
864
 
853
865
  selector.freeze
854
866
  end
855
867
 
856
868
  def coerce_effect_label_list(labels, where)
857
869
  labels.map(&:to_s).each do |label|
858
- raise ArgumentError, "#{where} is not a well-formed effect label: #{label.inspect}" unless
870
+ raise ConfigurationError, "#{where} is not a well-formed effect label: #{label.inspect}" unless
859
871
  Effects::Label.valid?(label)
860
872
  end.uniq.sort.freeze
861
873
  end
@@ -886,7 +898,7 @@ module Rigor
886
898
  when Hash
887
899
  entry.to_h { |k, v| [k.to_s, v] }.freeze
888
900
  else
889
- raise ArgumentError,
901
+ raise ConfigurationError,
890
902
  "plugin configuration entry must be a String or Hash, got #{entry.inspect}"
891
903
  end
892
904
  end
@@ -903,7 +915,7 @@ module Rigor
903
915
  def coerce_target_ruby(value)
904
916
  s = value.to_s
905
917
  unless s.match?(TARGET_RUBY_FORMAT)
906
- raise ArgumentError,
918
+ raise ConfigurationError,
907
919
  "target_ruby must be a version (e.g. \"3.4\", \"4.0\", \"3.4.0\") or \"latest\", got #{value.inspect}"
908
920
  end
909
921
 
@@ -924,11 +936,11 @@ module Rigor
924
936
  # disabling parallelism.
925
937
  def coerce_parallel_workers(value)
926
938
  integer = Integer(value)
927
- raise ArgumentError, "parallel.workers must be >= 0, got #{value.inspect}" if integer.negative?
939
+ raise ConfigurationError, "parallel.workers must be >= 0, got #{value.inspect}" if integer.negative?
928
940
 
929
941
  integer
930
942
  rescue TypeError, ArgumentError => e
931
- raise ArgumentError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
943
+ raise ConfigurationError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
932
944
  end
933
945
 
934
946
  # ADR-22 WD2 (b) — `baseline: <path>` activates the file; `baseline: false` is the explicit-disable form
@@ -955,7 +967,7 @@ module Rigor
955
967
  def coerce_network_policy(value)
956
968
  sym = value.to_sym
957
969
  unless VALID_NETWORK_POLICIES.include?(sym)
958
- raise ArgumentError,
970
+ raise ConfigurationError,
959
971
  "plugins_io.network must be one of #{VALID_NETWORK_POLICIES.inspect}, got #{value.inspect}"
960
972
  end
961
973
 
@@ -967,7 +979,7 @@ module Rigor
967
979
  def coerce_severity_profile(value)
968
980
  sym = value.to_sym
969
981
  unless SeverityProfile::VALID_PROFILES.include?(sym)
970
- raise ArgumentError,
982
+ raise ConfigurationError,
971
983
  "severity_profile must be one of " \
972
984
  "#{SeverityProfile::VALID_PROFILES.inspect}, got #{value.inspect}"
973
985
  end
@@ -980,7 +992,7 @@ module Rigor
980
992
  # {SeverityProfile::VALID_SEVERITIES} symbols (`:error` / `:warning` / `:info` / `:off`). Unknown
981
993
  # severities raise; unknown rule ids are silently kept (the override is inert until the rule lands).
982
994
  def coerce_severity_overrides(value)
983
- raise ArgumentError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
995
+ raise ConfigurationError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
984
996
 
985
997
  value.to_h do |k, v|
986
998
  # YAML 1.1 parses bare `off`/`on`/`no`/`yes`/`true`/`false` as booleans, so a user who wrote `off` (a
@@ -988,7 +1000,7 @@ module Rigor
988
1000
  # `to_sym` blows up with a backtrace.
989
1001
  unless v.is_a?(String) || v.is_a?(Symbol)
990
1002
  hint = v == false ? %( — did you mean the string "off"?) : ""
991
- raise ArgumentError,
1003
+ raise ConfigurationError,
992
1004
  "severity_overrides[#{k.inspect}] is #{v.inspect}, a YAML boolean#{hint} " \
993
1005
  "Bare off/on/no/yes/true/false are parsed as booleans; quote the severity " \
994
1006
  "(e.g. \"off\")."
@@ -996,7 +1008,7 @@ module Rigor
996
1008
 
997
1009
  sym = v.to_sym
998
1010
  unless SeverityProfile::VALID_SEVERITIES.include?(sym)
999
- raise ArgumentError,
1011
+ raise ConfigurationError,
1000
1012
  "severity_overrides[#{k.inspect}] must be one of " \
1001
1013
  "#{SeverityProfile::VALID_SEVERITIES.inspect}, got #{v.inspect}"
1002
1014
  end
@@ -1016,7 +1028,7 @@ module Rigor
1016
1028
  when Array then { "mode" => "list", "ids" => freeze_ids(value) }
1017
1029
  when Hash then coerce_bleeding_edge_hash(value)
1018
1030
  else
1019
- raise ArgumentError,
1031
+ raise ConfigurationError,
1020
1032
  "bleeding_edge must be true, false, a list of feature ids, " \
1021
1033
  "or { all: true, except: [...] }, got #{value.inspect}"
1022
1034
  end.freeze
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # A mistake in the project's configuration — a value `Configuration` cannot proceed on (the tier-2
5
+ # failures `docs/internal-spec/config.md` enumerates), or a `.rigor.yml` key the loader accepted whose
6
+ # meaning only resolves later, like an `effects.snapshot.reach:` preset name.
7
+ #
8
+ # It exists so the CLI can tell **the user got the file wrong** apart from **Rigor got itself wrong**.
9
+ # Both used to reach the terminal as an uncaught `ArgumentError` with a thirty-frame backtrace naming a
10
+ # file inside `lib/rigor/`, which reads as a crash: the reader's first move is to file a bug, not to
11
+ # fix the key the message already named (#433). {CLI#run} rescues this class and renders it as a single
12
+ # `rigor:` line.
13
+ #
14
+ # It stays an `ArgumentError` subclass deliberately. `Configuration` raised `ArgumentError` from every
15
+ # coercion since the beginning, that is the documented tier-2 contract, and callers outside the CLI —
16
+ # the language server, embedders, the suite — rescue it by that name. Narrowing the class is a
17
+ # presentation change, not a contract change.
18
+ class ConfigurationError < ArgumentError
19
+ end
20
+ end
@@ -128,7 +128,7 @@ module Rigor
128
128
  # {Accumulator#record} is what stops it paying for the `Data` and the origin lookup below.
129
129
  return if accumulator.recorded?(node)
130
130
 
131
- dynamic = receiver.is_a?(Type::Dynamic)
131
+ dynamic = dynamic_receiver?(receiver)
132
132
  class_name, kind = descriptor_for(receiver)
133
133
  accumulator.record(
134
134
  node,
@@ -160,9 +160,46 @@ module Rigor
160
160
  when Type::HashShape then ["Hash", :instance]
161
161
  when Type::Constant then [receiver.value.class.name, :instance]
162
162
  when Type::Dynamic then descriptor_for(receiver.static_facet)
163
+ when Type::Union then union_descriptor(receiver)
163
164
  end
164
165
  end
165
166
 
167
+ # Whether the typer's verdict on this receiver leaves its class a guess. A bare `Dynamic` is the
168
+ # original case; a union carrying a `Dynamic` arm is the same knowledge in a different shape, and
169
+ # before #455 it was the shape that said nothing — no class, no edge, and no taint, so the summary
170
+ # read *exhaustive* while an arm of its receiver was admittedly unknown.
171
+ def dynamic_receiver?(receiver)
172
+ return true if receiver.is_a?(Type::Dynamic)
173
+
174
+ receiver.is_a?(Type::Union) && receiver.members.any?(Type::Dynamic)
175
+ end
176
+
177
+ # A union projects to a class exactly when its non-nil arms all project to the SAME one (#455).
178
+ #
179
+ # `T?` is the case that pays, and it is not a corner: ADR-58 contributes a declaration-sourced
180
+ # `nil` to every instance variable not written in `initialize`, so **every cross-method ivar read
181
+ # is a union** — `@group.save` reads `Group | nil` where the same `Group.new.save` two lines up
182
+ # reads `Group`. Without this arm the receiver had no class, so the plugin row never matched, the
183
+ # edge was dropped, and the site contributed nothing *while the summary still read exhaustive*.
184
+ # Whether the nil arm means the call happens at all is a question for `possible-nil-receiver`; it
185
+ # says nothing about what the call does when it does happen, which is the only question here.
186
+ #
187
+ # Arms that disagree (`File | StringIO`) still project to nothing. Answering with either one would
188
+ # state an effect no single execution need perform, and answering with both is a shape the record
189
+ # has no room for — one call site carries one receiver class.
190
+ def union_descriptor(receiver)
191
+ descriptors = receiver.members.reject { |member| nil_member?(member) }.map { |member| descriptor_for(member) }
192
+ first = descriptors.first
193
+ return nil if first.nil?
194
+
195
+ descriptors.all? { |descriptor| descriptor == first } ? first : nil
196
+ end
197
+
198
+ def nil_member?(member)
199
+ (member.is_a?(Type::Constant) && member.value.nil?) ||
200
+ (member.is_a?(Type::Nominal) && member.class_name == "NilClass")
201
+ end
202
+
166
203
  # Fail-soft (WD13): the scan raising drops this file's summaries and never reaches `rigor check`.
167
204
  def build(accumulator)
168
205
  return FileCollection.empty(accumulator.path) if accumulator.root.nil?