rigortype 0.3.4 → 0.3.6

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/effects/core.yml +18 -1
  4. data/data/effects/registry.yml +31 -3
  5. data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
  6. data/docs/handbook/02-everyday-types.md +1 -1
  7. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  8. data/docs/handbook/08-understanding-errors.md +1 -1
  9. data/docs/handbook/09-plugins.md +2 -2
  10. data/docs/handbook/10-sorbet.md +1 -1
  11. data/docs/handbook/README.md +2 -2
  12. data/docs/handbook/appendix-go.md +1 -1
  13. data/docs/handbook/appendix-java-csharp.md +2 -2
  14. data/docs/handbook/appendix-mypy.md +1 -1
  15. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  16. data/docs/handbook/appendix-rust.md +1 -1
  17. data/docs/handbook/appendix-type-theory.md +2 -2
  18. data/docs/handbook/appendix-typescript.md +4 -4
  19. data/docs/manual/02-cli-reference.md +73 -22
  20. data/docs/manual/03-configuration.md +19 -2
  21. data/docs/manual/04-diagnostics.md +15 -2
  22. data/docs/manual/08-skills.md +1 -1
  23. data/docs/manual/11-ci.md +38 -1
  24. data/docs/manual/12-caching.md +40 -1
  25. data/docs/manual/15-type-protection-coverage.md +1 -1
  26. data/docs/manual/16-rbs-extended-annotations.md +15 -2
  27. data/docs/manual/18-removing-dead-code.md +13 -4
  28. data/docs/manual/19-effect-labels.md +678 -0
  29. data/docs/manual/README.md +7 -2
  30. data/docs/manual/ci-templates/README.md +9 -0
  31. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  32. data/docs/manual/plugins/rigor-activejob.md +3 -3
  33. data/docs/manual/plugins/rigor-activerecord.md +4 -4
  34. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  35. data/docs/manual/plugins/rigor-rspec.md +6 -2
  36. data/lib/rigor/analysis/diagnostic.rb +17 -6
  37. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  38. data/lib/rigor/analysis/reachability/graph.rb +30 -11
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  40. data/lib/rigor/analysis/reachability/scan.rb +20 -4
  41. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  42. data/lib/rigor/analysis/rule_catalog.rb +10 -3
  43. data/lib/rigor/analysis/run_cache_key.rb +12 -0
  44. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  45. data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +15 -9
  48. data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
  49. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  50. data/lib/rigor/analysis/runner.rb +163 -31
  51. data/lib/rigor/analysis/worker_session.rb +3 -1
  52. data/lib/rigor/cache/file_digest.rb +20 -2
  53. data/lib/rigor/cli/check_command.rb +67 -48
  54. data/lib/rigor/cli/coverage_command.rb +5 -6
  55. data/lib/rigor/cli/doc_links.rb +100 -0
  56. data/lib/rigor/cli/docs_command.rb +32 -2
  57. data/lib/rigor/cli/effects_command.rb +149 -10
  58. data/lib/rigor/cli/effects_diff_renderer.rb +88 -14
  59. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  60. data/lib/rigor/cli/effects_renderer.rb +41 -3
  61. data/lib/rigor/cli/effects_report.rb +116 -6
  62. data/lib/rigor/cli/effects_snapshot_command.rb +75 -11
  63. data/lib/rigor/cli/unused_command.rb +65 -16
  64. data/lib/rigor/cli.rb +33 -9
  65. data/lib/rigor/configuration.rb +37 -25
  66. data/lib/rigor/configuration_error.rb +20 -0
  67. data/lib/rigor/effects/collector.rb +38 -1
  68. data/lib/rigor/effects/definition_lines.rb +100 -0
  69. data/lib/rigor/effects/entry_points.rb +47 -1
  70. data/lib/rigor/effects/envelope_check.rb +18 -1
  71. data/lib/rigor/effects/file_collection.rb +18 -4
  72. data/lib/rigor/effects/framework_units.rb +68 -13
  73. data/lib/rigor/effects/inline_anchor.rb +134 -0
  74. data/lib/rigor/effects/liskov_check.rb +17 -8
  75. data/lib/rigor/effects/plugin_facts.rb +62 -13
  76. data/lib/rigor/effects/propagator.rb +79 -19
  77. data/lib/rigor/effects/registry.rb +10 -3
  78. data/lib/rigor/effects/scanner.rb +21 -9
  79. data/lib/rigor/effects/signature_sources.rb +29 -2
  80. data/lib/rigor/effects/snapshot.rb +74 -26
  81. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  82. data/lib/rigor/effects/taint_cause.rb +1 -0
  83. data/lib/rigor/effects/unit_scan.rb +87 -12
  84. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  85. data/lib/rigor/plugin/base.rb +4 -0
  86. data/lib/rigor/plugin/box.rb +18 -2
  87. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  88. data/lib/rigor/plugin/manifest.rb +34 -10
  89. data/lib/rigor/plugin/registry.rb +10 -3
  90. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  91. data/lib/rigor/rbs_extended.rb +22 -2
  92. data/lib/rigor/version.rb +1 -1
  93. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
  94. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  95. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  96. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  97. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
  98. data/skills/rigor-ci-setup/SKILL.md +2 -2
  99. data/skills/rigor-editor-setup/SKILL.md +2 -2
  100. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  101. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  102. data/skills/rigor-plugin-review/SKILL.md +3 -3
  103. metadata +10 -1
@@ -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?
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "method_key"
6
+
7
+ module Rigor
8
+ module Effects
9
+ # Where a method key's `def` is written, resolved from the file the key was already traced to
10
+ # (#435).
11
+ #
12
+ # A drift row names the file out of `Runner#effect_sources`, which rides the cached summary entry
13
+ # and is therefore free. The **line** is not in any value the effects surfaces hold: the discovery
14
+ # tables {EnvelopeCheck::Positions} reads are built by one Prism parse of every project file, which
15
+ # is exactly what [ADR-104](../../../docs/adr/104-effects-boot-slim-probe.md) removed from this
16
+ # command — a warm `rigor effects check` is fast *because* it never parses the project.
17
+ #
18
+ # So this parses the drift's own files and nothing else: an index is built the first time a row asks
19
+ # about a path, and a report with no rows builds none. The cost is proportional to the drift, not to
20
+ # the project — the {EnvelopeCheck::DeferredPositions} shape (#479) applied one layer up, and the
21
+ # reason it can be one layer up is that the caller already knows the file.
22
+ #
23
+ # It is deliberately not a second discovery pass. It answers `def`s and only `def`s: a key whose
24
+ # method has no Ruby `def` at all — a synthesized accessor — keeps the file and loses the line,
25
+ # which is the same degradation `Positions` makes when it falls back to the class's own source.
26
+ class DefinitionLines
27
+ TOPLEVEL = "<toplevel>"
28
+ private_constant :TOPLEVEL
29
+
30
+ NO_LINES = {}.freeze
31
+ private_constant :NO_LINES
32
+
33
+ # @param key [String] an effect unit key — `Tracer::Loud#emit`, `Net::HTTP.get`.
34
+ # @param path [String] the file the key was traced to.
35
+ # @return [Integer, nil] the `def`'s line, or nil when this file does not spell that key with a
36
+ # `def` — an unreadable file, a syntax error, and a synthesized method all land here.
37
+ def for(key:, path:)
38
+ index_for(path)[key]
39
+ end
40
+
41
+ private
42
+
43
+ def index_for(path)
44
+ @indexes ||= {}
45
+ @indexes[path] ||= build_index(path)
46
+ end
47
+
48
+ # First `def` wins: a file that reopens the same method twice has two lines and only one of them is
49
+ # where a reader starts.
50
+ def build_index(path)
51
+ result = Prism.parse_file(path.to_s)
52
+ return NO_LINES unless result.success?
53
+
54
+ {}.tap { |index| walk(result.value, [], singleton: false, index: index) }
55
+ rescue StandardError
56
+ NO_LINES
57
+ end
58
+
59
+ # The nesting is tracked, never resolved: `class Tracer::Loud` inside `module Tracer` spells a key
60
+ # this cannot see, and answering it would need the constant resolution an engine-free path does not
61
+ # have. A key it cannot spell keeps its file, which is what the row printed before this class existed.
62
+ def walk(node, nesting, singleton:, index:)
63
+ case node
64
+ when Prism::ModuleNode, Prism::ClassNode
65
+ name = constant_name(node.constant_path)
66
+ return if name.nil?
67
+
68
+ walk_children(node.body, nesting + [name], singleton: false, index: index)
69
+ when Prism::SingletonClassNode
70
+ walk_children(node.body, nesting, singleton: true, index: index)
71
+ when Prism::DefNode
72
+ record(node, nesting, singleton: singleton, index: index)
73
+ walk_children(node.body, nesting, singleton: singleton, index: index)
74
+ else
75
+ walk_children(node, nesting, singleton: singleton, index: index)
76
+ end
77
+ end
78
+
79
+ def walk_children(node, nesting, singleton:, index:)
80
+ node&.compact_child_nodes&.each { |child| walk(child, nesting, singleton: singleton, index: index) }
81
+ end
82
+
83
+ def record(node, nesting, singleton:, index:)
84
+ separator = singleton || node.receiver.is_a?(Prism::SelfNode) ? "." : "#"
85
+ owner = nesting.empty? ? TOPLEVEL : nesting.join("::")
86
+ key = "#{owner}#{separator}#{node.name}"
87
+ index[key] ||= node.location.start_line
88
+ end
89
+
90
+ def constant_name(node)
91
+ case node
92
+ when Prism::ConstantReadNode then node.name.to_s
93
+ when Prism::ConstantPathNode then node.full_name
94
+ end
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../configuration_error"
4
+
3
5
  module Rigor
4
6
  module Effects
5
7
  # The named entry-point presets `effects.snapshot.reach:` may adopt (ADR-103 WD14).
@@ -30,9 +32,19 @@ module Rigor
30
32
  # than becoming an unfindable key.
31
33
  NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/
32
34
 
35
+ # A plugin's registration is wrong: a malformed preset name, or two plugins claiming one name with
36
+ # different globs. Nothing the user wrote, so it never reaches them as a configuration error —
37
+ # {PluginFacts} downgrades it to a warning.
33
38
  class Error < StandardError
34
39
  end
35
40
 
41
+ # The user's `effects.snapshot.reach:` names a preset no active plugin registered — the most common
42
+ # of these being chapter 03's `reach: [rails]` without `rigor-railties` in `plugins:` (#433). A
43
+ # {Rigor::ConfigurationError}, not an {Error}, because the offending text is in `.rigor.yml`: it is
44
+ # the CLI's to render as a `rigor:` line, not a crash.
45
+ class UnknownPreset < Rigor::ConfigurationError
46
+ end
47
+
36
48
  @presets = {}
37
49
 
38
50
  class << self
@@ -73,7 +85,8 @@ module Rigor
73
85
  Array(presets).each { |preset| register(preset.name, preset.globs) }
74
86
  end
75
87
 
76
- # Every registered preset name, sorted. Empty in this slice.
88
+ # Every registered preset name, sorted. Empty until a plugin declaring `effect_entry_points:`
89
+ # loads, which is what makes {availability} worth printing.
77
90
  def names
78
91
  @presets.keys.sort.freeze
79
92
  end
@@ -83,6 +96,39 @@ module Rigor
83
96
  @presets[name.to_s]
84
97
  end
85
98
 
99
+ # The globs `name` stands for, or the configuration error that says what this project could have
100
+ # written instead.
101
+ #
102
+ # The existence check lives here and runs from the snapshot build rather than from
103
+ # `Configuration` — presets are registered by plugins, and the plugins load FROM the configuration
104
+ # being validated, so at load time the registry is still empty. See {name?} for the shape check
105
+ # that does run at load.
106
+ def resolve!(name)
107
+ globs_for(name) || raise(
108
+ UnknownPreset,
109
+ "effects.snapshot.reach names no registered entry-point preset: #{name.to_s.inspect} " \
110
+ "(#{availability})"
111
+ )
112
+ end
113
+
114
+ # What this project may write in `effects.snapshot.reach:`, as one parenthetical clause.
115
+ #
116
+ # Single-homed here because the answer is a property of the loaded plugin set rather than of any
117
+ # one message: the unregistered-preset error (#433) and the note `rigor effects update` prints
118
+ # when `reach:` is empty (#436) have to give the same answer, and neither can hard-code a list
119
+ # that moves with `plugins:`.
120
+ #
121
+ # @return [String]
122
+ def availability
123
+ if @presets.empty?
124
+ return "no plugin in this project registers an entry-point preset — a preset is named by the " \
125
+ "plugin that models the framework, so listing that plugin under `plugins:` is what " \
126
+ "registers one"
127
+ end
128
+
129
+ "presets registered in this project: #{names.join(', ')}"
130
+ end
131
+
86
132
  # Drops every registration. For specs only — production code registers and never unregisters.
87
133
  def reset!
88
134
  @presets = {}
@@ -83,6 +83,21 @@ module Rigor
83
83
  end
84
84
  end
85
85
 
86
+ # {Positions} behind a thunk: the discovery tables — one Prism parse of every project file —
87
+ # are built on the first `.for`, which both judgments reach only once a finding is being
88
+ # constructed. A clean judgment, the common CI case, therefore forces no discovery and parses
89
+ # nothing; that is the whole point of this class existing rather than the pass forcing the
90
+ # tables up front.
91
+ class DeferredPositions
92
+ def initialize(&build)
93
+ @build = build
94
+ end
95
+
96
+ def for(key)
97
+ (@positions ||= @build.call).for(key)
98
+ end
99
+ end
100
+
86
101
  NO_FINDINGS = [].freeze
87
102
  private_constant :NO_FINDINGS
88
103
 
@@ -93,7 +108,9 @@ module Rigor
93
108
  # @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
94
109
  # @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
95
110
  # to the classes they select ({ConfigEnvelopes.for_classes}), to distribute at the lowest precedence.
96
- # @param positions [Positions] the discovery tables a finding's `def` position is read from.
111
+ # @param positions [Positions, DeferredPositions] the discovery tables a finding's `def`
112
+ # position is read from — consulted only when a finding is built, so a deferred value's
113
+ # discovery force is reached exactly as often as a finding exists.
97
114
  # @param apply_tolerated [Boolean] false judges against the undischarged-by-policy `proven` lane —
98
115
  # the `--no-tolerated-effects` audit switch.
99
116
  # @return [Array<Finding>] sorted by position then key then label, so a run explains identically twice.
@@ -30,7 +30,16 @@ module Rigor
30
30
  # receiver, or a construct that is not a call). `kind` is `:instance` for a `Nominal` receiver and
31
31
  # `:singleton` for a `Singleton` one. `self_call` marks an implicit-self call, which is the only shape
32
32
  # whose failure to resolve is an `unresolved-self-call` taint rather than silence.
33
- Edge = Data.define(:receiver_class, :kind, :selector, :self_call)
33
+ #
34
+ # `super_call` marks the edge a `super` contributes (#446). It carries the ENCLOSING unit's class and
35
+ # selector rather than a receiver's, and the propagator resolves it against the ancestry *above* that
36
+ # class with no closed-world override join — a different question from every other edge, which is why
37
+ # it is a field rather than a convention over the other three.
38
+ Edge = Data.define(:receiver_class, :kind, :selector, :self_call, :super_call) do
39
+ # Defaulted because every producer but the `super` one records an ordinary call, and an ordinary
40
+ # call is not a `super`.
41
+ def initialize(super_call: false, **) = super
42
+ end
34
43
 
35
44
  NO_TABLE = {}.freeze
36
45
  private_constant :NO_TABLE
@@ -144,17 +153,22 @@ module Rigor
144
153
  end
145
154
 
146
155
  # Edge lists are sorted so a marshalled worker collection and a sequential one are `==` and the
147
- # report they feed is byte-identical. The key is TOTAL over the de-duplicated list — `self_call` is
148
- # in it because two edges can otherwise agree on every other field, and `sort_by` is not stable.
156
+ # report they feed is byte-identical. The key is TOTAL over the de-duplicated list — `self_call` and
157
+ # `super_call` are in it because two edges can otherwise agree on every other field (`def emit; super;
158
+ # emit; end` records both), and `sort_by` is not stable.
149
159
  def freeze_edges(table)
150
160
  return NO_TABLE if table.empty?
151
161
 
152
162
  table.transform_values do |list|
153
163
  sorted = list.uniq
154
- sorted.sort_by! { |edge| [edge.receiver_class.to_s, edge.kind.to_s, edge.selector, edge.self_call ? 1 : 0] }
164
+ sorted.sort_by! { |edge| edge_order(edge) }
155
165
  sorted.freeze
156
166
  end.freeze
157
167
  end
168
+
169
+ def edge_order(edge)
170
+ [edge.receiver_class.to_s, edge.kind.to_s, edge.selector, edge.self_call ? 1 : 0, edge.super_call ? 1 : 0]
171
+ end
158
172
  end
159
173
  end
160
174
  end