rigortype 0.3.3 → 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 (171) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +860 -0
  16. data/data/effects/registry.yml +95 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +359 -2
  19. data/docs/manual/03-configuration.md +101 -1
  20. data/docs/manual/04-diagnostics.md +8 -3
  21. data/docs/manual/11-ci.md +37 -0
  22. data/docs/manual/12-caching.md +39 -0
  23. data/docs/manual/16-rbs-extended-annotations.md +90 -0
  24. data/docs/manual/18-removing-dead-code.md +326 -0
  25. data/docs/manual/19-effect-labels.md +671 -0
  26. data/docs/manual/README.md +10 -0
  27. data/docs/manual/ci-templates/README.md +9 -0
  28. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  29. data/docs/manual/plugins/rigor-activejob.md +47 -0
  30. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  31. data/docs/manual/plugins/rigor-pundit.md +26 -0
  32. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  33. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  34. data/docs/manual/plugins/rigor-rspec.md +8 -0
  35. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  36. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +168 -3
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  46. data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
  47. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  48. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  49. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  50. data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  52. data/lib/rigor/analysis/runner.rb +429 -10
  53. data/lib/rigor/analysis/worker_session.rb +60 -2
  54. data/lib/rigor/bleeding_edge.rb +22 -0
  55. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  56. data/lib/rigor/cli/check_command.rb +13 -1
  57. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  58. data/lib/rigor/cli/effects_command.rb +245 -0
  59. data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
  60. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  61. data/lib/rigor/cli/effects_renderer.rb +92 -0
  62. data/lib/rigor/cli/effects_report.rb +163 -0
  63. data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
  64. data/lib/rigor/cli/unused_command.rb +288 -0
  65. data/lib/rigor/cli.rb +28 -1
  66. data/lib/rigor/configuration/severity_profile.rb +23 -0
  67. data/lib/rigor/configuration.rb +322 -28
  68. data/lib/rigor/configuration_error.rb +20 -0
  69. data/lib/rigor/effects/attribution.rb +76 -0
  70. data/lib/rigor/effects/catalog.rb +275 -0
  71. data/lib/rigor/effects/collector.rb +219 -0
  72. data/lib/rigor/effects/config_envelopes.rb +185 -0
  73. data/lib/rigor/effects/discharge.rb +69 -0
  74. data/lib/rigor/effects/effect_table.rb +92 -0
  75. data/lib/rigor/effects/entry_points.rb +139 -0
  76. data/lib/rigor/effects/envelope.rb +86 -0
  77. data/lib/rigor/effects/envelope_check.rb +172 -0
  78. data/lib/rigor/effects/envelope_index.rb +157 -0
  79. data/lib/rigor/effects/file_collection.rb +174 -0
  80. data/lib/rigor/effects/framework_units.rb +222 -0
  81. data/lib/rigor/effects/identity.rb +104 -0
  82. data/lib/rigor/effects/inline_anchor.rb +134 -0
  83. data/lib/rigor/effects/label.rb +77 -0
  84. data/lib/rigor/effects/label_intent.rb +73 -0
  85. data/lib/rigor/effects/label_set.rb +136 -0
  86. data/lib/rigor/effects/liskov_check.rb +167 -0
  87. data/lib/rigor/effects/local_ownership.rb +132 -0
  88. data/lib/rigor/effects/method_key.rb +40 -0
  89. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  90. data/lib/rigor/effects/narrowing.rb +202 -0
  91. data/lib/rigor/effects/origin.rb +66 -0
  92. data/lib/rigor/effects/path_finder.rb +89 -0
  93. data/lib/rigor/effects/plugin_facts.rb +384 -0
  94. data/lib/rigor/effects/propagator.rb +335 -0
  95. data/lib/rigor/effects/registry.rb +200 -0
  96. data/lib/rigor/effects/scanner.rb +302 -0
  97. data/lib/rigor/effects/signature_sources.rb +90 -0
  98. data/lib/rigor/effects/snapshot.rb +396 -0
  99. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  100. data/lib/rigor/effects/summary.rb +154 -0
  101. data/lib/rigor/effects/taint_cause.rb +39 -0
  102. data/lib/rigor/effects/unit_scan.rb +647 -0
  103. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  104. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  105. data/lib/rigor/effects.rb +47 -0
  106. data/lib/rigor/environment/rbs_loader.rb +34 -0
  107. data/lib/rigor/environment.rb +8 -1
  108. data/lib/rigor/flow_contribution/element.rb +1 -0
  109. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  110. data/lib/rigor/flow_contribution/merger.rb +16 -1
  111. data/lib/rigor/flow_contribution.rb +20 -4
  112. data/lib/rigor/inference/expression_typer.rb +18 -4
  113. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  114. data/lib/rigor/inference/scope_indexer.rb +15 -3
  115. data/lib/rigor/plugin/base.rb +28 -0
  116. data/lib/rigor/plugin/box.rb +18 -2
  117. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  118. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  119. data/lib/rigor/plugin/effect_edge.rb +101 -0
  120. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  121. data/lib/rigor/plugin/first_party.rb +57 -0
  122. data/lib/rigor/plugin/isolation.rb +4 -1
  123. data/lib/rigor/plugin/manifest.rb +127 -3
  124. data/lib/rigor/plugin/registry.rb +55 -0
  125. data/lib/rigor/plugin.rb +1 -0
  126. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  127. data/lib/rigor/rbs_extended.rb +188 -0
  128. data/lib/rigor/reflection.rb +131 -30
  129. data/lib/rigor/version.rb +1 -1
  130. data/lib/rigor.rb +1 -0
  131. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  133. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  134. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  135. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  136. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  137. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  138. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  139. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  140. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  141. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  142. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  143. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  144. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  145. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  146. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  147. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
  148. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  149. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  150. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  151. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  152. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  153. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  154. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  157. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  158. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  159. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  160. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  161. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  162. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  164. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  165. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
  166. data/sig/rigor/analysis/fact_store.rbs +1 -0
  167. data/sig/rigor/inference.rbs +2 -0
  168. data/sig/rigor/rbs_extended.rbs +6 -0
  169. data/sig/rigor.rbs +17 -1
  170. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  171. metadata +84 -1
@@ -3,6 +3,11 @@
3
3
  require_relative "type"
4
4
  require_relative "builtins/imported_refinements"
5
5
  require_relative "flow_contribution"
6
+ require_relative "effects/envelope"
7
+ require_relative "effects/inline_anchor"
8
+ require_relative "effects/label"
9
+ require_relative "effects/label_set"
10
+ require_relative "effects/signature_sources"
6
11
  require_relative "rbs_extended/reporter"
7
12
  require_relative "rbs_extended/hkt_directives"
8
13
 
@@ -621,6 +626,189 @@ module Rigor
621
626
  facts.empty? ? nil : facts
622
627
  end
623
628
 
629
+ # ── Effect envelopes (ADR-103 WD1 / WD5 / WD14; #383) ──────────────────────────────────────────
630
+ #
631
+ # Two spellings reach the same value. `%a{pure}` is the ecosystem's existing purity annotation
632
+ # (rbs core and Steep both carry it) and reads as the EMPTY envelope; `%a{rigor:v1:effect …}` is
633
+ # Rigor's labelled bound. `rigor:v1:pure` is deliberately NOT implemented — WD14 fixed `%a{pure}`
634
+ # as the only purity spelling.
635
+
636
+ # The bare rbs-native purity annotation. Matched whole, with surrounding whitespace tolerated, so
637
+ # `%a{purely}` and `%a{pure io}` are not it.
638
+ PURE_ANNOTATION_PATTERN = /\A\s*pure\s*\z/
639
+ private_constant :PURE_ANNOTATION_PATTERN
640
+
641
+ # `%a{rigor:v1:effect io.db, nondet.time}` — a space-separated head (the `assert` / `conforms-to`
642
+ # family), then a comma-separated list of BARE label tokens. There is no parenthesised comment
643
+ # form: RBS has real comments. The payload group is optional so that `%a{rigor:v1:effect}` still
644
+ # MATCHES the directive and is reported as malformed rather than read as "not a directive".
645
+ EFFECT_DIRECTIVE_PATTERN = /\Arigor:v1:effect(?:\s+(?<labels>.*?))?\s*\z/
646
+ private_constant :EFFECT_DIRECTIVE_PATTERN
647
+
648
+ # The outcome of reading one `%a{rigor:v1:effect …}` payload.
649
+ #
650
+ # `bound` is the declared {Rigor::Effects::LabelSet}, or {Effects::LabelSet::TOP} when the tag
651
+ # could not be given a meaning. `malformed` says the grammar was violated (an empty list, a token
652
+ # that is not a label); `unknown_labels` says the grammar held but the registry does not
653
+ # recognise a spelling — a different condition with different handling, and what #384's
654
+ # `effect.unknown-label` reads. `labels` is the list exactly as written, recognised or not, which
655
+ # is what lets the diagnostic ask whether some OTHER member of the list was known.
656
+ EffectAnnotation = Data.define(:bound, :labels, :unknown_labels, :malformed) do
657
+ def malformed? = malformed
658
+ def top? = bound.top?
659
+ end
660
+
661
+ NO_EFFECT_LABELS = [].freeze
662
+ private_constant :NO_EFFECT_LABELS
663
+
664
+ # Whether `string` is the bare `%a{pure}` annotation.
665
+ def pure_annotation?(string)
666
+ !string.nil? && PURE_ANNOTATION_PATTERN.match?(string)
667
+ end
668
+
669
+ # Reads one annotation string as an effect-envelope payload.
670
+ #
671
+ # Returns `nil` when the string is not a `rigor:v1:effect` directive at all (so a caller can walk
672
+ # an annotation list without pre-filtering). Otherwise an {EffectAnnotation}:
673
+ #
674
+ # - well-formed and fully recognised → the declared bound;
675
+ # - malformed (empty list, a token outside the label grammar) → {Effects::LabelSet::TOP}, and a
676
+ # `record_unresolved` event on `reporter`;
677
+ # - well-formed but carrying a spelling `registry` does not know → {Effects::LabelSet::TOP} and
678
+ # the unrecognised spellings, because an unknown label makes the WHOLE tag ⊤ rather than the
679
+ # subset that happened to parse. Narrowing to the recognised subset would turn a typo into
680
+ # findings on correct code; widening suppresses them, which is the direction the false-positive
681
+ # budget runs (ADR-5).
682
+ def parse_effect_annotation(string, registry: nil, reporter: nil, source_location: nil)
683
+ match = EFFECT_DIRECTIVE_PATTERN.match(string.to_s)
684
+ return nil if match.nil?
685
+
686
+ tokens = match[:labels].to_s.split(",", -1).map(&:strip)
687
+ if tokens.empty? || tokens.any? { |token| !Effects::Label.valid?(token) }
688
+ record_unresolved(reporter, string, source_location)
689
+ return EffectAnnotation.new(
690
+ bound: Effects::LabelSet::TOP, labels: NO_EFFECT_LABELS, unknown_labels: NO_EFFECT_LABELS, malformed: true
691
+ )
692
+ end
693
+
694
+ tokens = tokens.freeze
695
+ unknown = registry.nil? ? [] : tokens.reject { |token| registry.known?(token) }
696
+ EffectAnnotation.new(
697
+ bound: unknown.empty? ? Effects::LabelSet.new(tokens) : Effects::LabelSet::TOP,
698
+ labels: tokens, unknown_labels: unknown.uniq.sort.freeze, malformed: false
699
+ )
700
+ end
701
+
702
+ # Reads the effect envelope off an annotation list — `RBS::Definition::Method#annotations`, or the
703
+ # `#annotations` of an `RBS::AST::Members::MethodDefinition` / class declaration, which carry the
704
+ # same `(string, location)` shape.
705
+ #
706
+ # Precedence, per the spec: `%a{pure}` and `%a{rigor:v1:effect …}` on ONE declaration are
707
+ # contradictory and `pure` wins; the contradiction is recorded on `reporter` (the existing
708
+ # `RBS::Extended` conflict channel) rather than silently resolved. Returns `nil` when the list
709
+ # carries neither spelling.
710
+ #
711
+ # @param annotations [Array<#string>] the node's annotations, in source order.
712
+ # @param owner_key [String] the method key (`Class#m` / `Class.m`) or class name the bound binds.
713
+ # @param source [Symbol] {Effects::Envelope::SOURCES} member to stamp when the labelled spelling
714
+ # matched; `%a{pure}` always stamps `:pure_annotation`, and a class-level read overrides both.
715
+ def read_effect_envelope(annotations, owner_key:, source: :effect_annotation, registry: nil, reporter: nil)
716
+ return nil if annotations.nil? || annotations.empty?
717
+
718
+ pure = annotations.find { |annotation| pure_annotation?(annotation.string) }
719
+ labelled = nil
720
+ parsed = nil
721
+ annotations.each do |annotation|
722
+ result = parse_effect_annotation(
723
+ annotation.string, registry: registry, reporter: reporter, source_location: annotation_location(annotation)
724
+ )
725
+ next if result.nil?
726
+
727
+ labelled = annotation
728
+ parsed = result
729
+ break
730
+ end
731
+ return nil if pure.nil? && labelled.nil?
732
+
733
+ if pure && labelled
734
+ record_unresolved(
735
+ reporter,
736
+ "`%a{pure}` and `%a{#{labelled.string}}` on one declaration are contradictory; `pure` wins",
737
+ annotation_location(pure)
738
+ )
739
+ end
740
+ return build_pure_envelope(pure, owner_key: owner_key, source: source) if pure
741
+
742
+ build_effect_envelope(labelled, parsed, owner_key: owner_key, source: source)
743
+ end
744
+
745
+ def build_pure_envelope(annotation, owner_key:, source:)
746
+ Effects::Envelope.build(
747
+ owner_key: owner_key,
748
+ bound: Effects::LabelSet::EMPTY,
749
+ source: source == :class_annotation ? :class_annotation : :pure_annotation,
750
+ location: render_annotation_location(annotation),
751
+ spelling: "%a{#{annotation.string}}"
752
+ )
753
+ end
754
+
755
+ def build_effect_envelope(annotation, parsed, owner_key:, source:)
756
+ Effects::Envelope.build(
757
+ owner_key: owner_key,
758
+ bound: parsed.bound,
759
+ source: source,
760
+ location: render_annotation_location(annotation),
761
+ spelling: "%a{#{annotation.string}}",
762
+ unknown_labels: parsed.unknown_labels,
763
+ declared_labels: parsed.labels
764
+ )
765
+ end
766
+
767
+ def annotation_location(annotation)
768
+ annotation.respond_to?(:location) ? annotation.location : nil
769
+ end
770
+
771
+ # `path:line` for the annotation, project-relative when it sits under the working directory, so a
772
+ # diagnostic message names `sig/foo.rbs:12` rather than an absolute path.
773
+ #
774
+ # A synthesized buffer is re-anchored here rather than at any of the surfaces that render it
775
+ # ({Effects::InlineAnchor}; #432). The buffer's own line numbers describe a document the author
776
+ # never saw, so an envelope carrying one is wrong for every consumer at once — the diagnostic, the
777
+ # `effect.unknown-label` position, `rigor explain`, the JSON formatter, LSP hover. Correcting it
778
+ # where the value is *built* is what makes them agree without each learning the mapping.
779
+ def render_annotation_location(annotation)
780
+ location = annotation_location(annotation)
781
+ return nil if location.nil?
782
+
783
+ buffer = location.respond_to?(:buffer) ? location.buffer : nil
784
+ name = buffer.respond_to?(:name) ? buffer.name.to_s : nil
785
+ return nil if name.nil? || name.empty?
786
+
787
+ path = relative_annotation_path(name)
788
+ "#{path}:#{annotation_line(annotation, location, buffer, path)}"
789
+ rescue StandardError
790
+ nil
791
+ end
792
+
793
+ # The line a reader can open. For a real `.rbs` that is the parser's own answer; for the
794
+ # `virtual:rbs-inline:…rb` buffer the writer produced, the annotation is found again in the Ruby
795
+ # source by its own spelling.
796
+ def annotation_line(annotation, location, buffer, path)
797
+ line = location.respond_to?(:start_line) ? location.start_line : 1
798
+ content = buffer.respond_to?(:content) ? buffer.content : nil
799
+ return line if content.nil?
800
+
801
+ Effects::InlineAnchor.ruby_line(
802
+ path: path, buffer: content, buffer_line: line, spelling: "%a{#{annotation.string}}"
803
+ )
804
+ end
805
+
806
+ # The buffer-name → readable-path rule lives with the walk that produces the buffers
807
+ # ({Effects::SignatureSources}), so a `virtual:` name is stripped identically wherever it surfaces.
808
+ def relative_annotation_path(name)
809
+ Effects::SignatureSources.source_path(name)
810
+ end
811
+
624
812
  # ADR-13 slice 3b — guards every reporter call so the in-RbsExtended-module call sites can record events
625
813
  # uniformly without nil-checking each time. When the reporter is nil (the v0.1.0 → v0.1.3 default for call
626
814
  # sites that do not yet thread `environment:`), the call is a no-op and the parser stays fail-soft.
@@ -44,6 +44,10 @@ module Rigor
44
44
  # out of scope for the v0.0.7 first pass; v0.1.0's plugin API added it as a separate
45
45
  # concern.
46
46
  module Reflection
47
+ # #354 — thread-local slot for the per-run ancestor-scope memo. See {.ancestor_constant_scopes}.
48
+ ANCESTOR_SCOPES_KEY = :__rigor_ancestor_constant_scopes__
49
+ private_constant :ANCESTOR_SCOPES_KEY
50
+
47
51
  module_function
48
52
 
49
53
  # @param class_name [String, Symbol]
@@ -118,42 +122,139 @@ module Rigor
118
122
  # reads it to type a constant read, and `Inference::Narrowing` reads it to recognise a
119
123
  # value-pinned `Constant[Regexp]` match-predicate operand.
120
124
  def resolve_constant_type(name, scope: Scope.empty)
125
+ prefix = enclosing_class_path(scope)
126
+
127
+ # Step 1 — `Module.nesting`, innermost first. Each entry contributes only its OWN constants.
128
+ walker = prefix
129
+ while walker && !walker.empty?
130
+ hit = constant_type_at("#{walker}::#{name}", scope)
131
+ return hit if hit
132
+
133
+ idx = walker.rindex("::")
134
+ walker = idx ? walker[0, idx] : nil
135
+ end
136
+
137
+ # Step 2 (#354) — the ancestors of the innermost cresting scope, which Ruby consults BEFORE
138
+ # falling back to the top level. Skipping this step did not merely lose a resolution: when the
139
+ # same name also exists at top level, step 3 answered a lookup Ruby gives to the ancestor, so
140
+ # `KEY` inside `class Sub < Base` typed as the top-level constant rather than `Base::KEY` — a
141
+ # wrong type on correct code, which outranks any worst-case reading (AGENTS.md
142
+ # § "Implementation Guidelines"). Only project ancestors are walked; an RBS-known superclass
143
+ # contributes no name here (see {.ancestor_constant_scopes}).
144
+ if prefix
145
+ ancestor_constant_scopes(prefix, scope).each do |ancestor|
146
+ hit = constant_type_at("#{ancestor}::#{name}", scope)
147
+ return hit if hit
148
+ end
149
+ end
150
+
151
+ # Step 3 — the bare name (top level).
152
+ constant_type_at(name, scope)
153
+ end
154
+
155
+ # One candidate name, consulted in source-precedence order: the class registry (yielding a
156
+ # `Singleton[C]`), source-discovered classes, in-source value constants, then RBS-side
157
+ # constants. In-source values win over RBS constant decls because the user's source is
158
+ # authoritative for its own constants. Returns nil when no source knows `candidate`.
159
+ def constant_type_at(candidate, scope)
121
160
  env = scope.environment
122
- discovered = scope.discovered_classes
123
- in_source = scope.in_source_constants
124
- lexical_constant_candidates(name, scope: scope).each do |candidate|
125
- singleton = env.singleton_for_name(candidate)
126
- return singleton if singleton
127
-
128
- in_source_class = discovered[candidate]
129
- return in_source_class if in_source_class
130
-
131
- # In-source value-bearing constants take precedence over RBS constant decls because
132
- # user code is the authoritative source for its own constants.
133
- in_source_value = in_source[candidate]
134
- return in_source_value if in_source_value
135
-
136
- value = env.constant_for_name(candidate)
137
- return value if value
161
+
162
+ singleton = env.singleton_for_name(candidate)
163
+ return singleton if singleton
164
+
165
+ in_source_class = scope.discovered_classes[candidate]
166
+ return in_source_class if in_source_class
167
+
168
+ in_source_value = scope.in_source_constants[candidate]
169
+ return in_source_value if in_source_value
170
+
171
+ env.constant_for_name(candidate)
172
+ end
173
+ private_class_method :constant_type_at
174
+
175
+ # #354 — the project classes and modules whose own constants `class_name` inherits, in Ruby's
176
+ # ancestor order: included / prepended modules before the superclass (Ruby places mixins nearer),
177
+ # transitively, breadth-first. `class_name` itself is excluded — step 1 already covered it.
178
+ #
179
+ # Only PROJECT ancestors appear. `Scope#superclass_of` / `#includes_of` carry as-written names
180
+ # from the discovery pre-pass, and an as-written name that resolves to no discovered class or
181
+ # module is dropped — so a `class Foo < ActiveRecord::Base` contributes nothing and a constant
182
+ # owned by an RBS-known ancestor still resolves only if the bare name reaches it at step 3. That
183
+ # gap is deliberate for this slice: widening to the RBS ancestor graph is a separate question
184
+ # with its own FP surface.
185
+ #
186
+ # Memoised per run because step 2 runs on every constant reference whose lexical candidates all
187
+ # miss — which is the common case for a core-class reference (`String` inside `class Foo`). The
188
+ # bucket keys on the identity of the runner-seeded run-generation token (ADR-84 WD2), falling
189
+ # back to the per-file discovery table for runner-less scopes, so a re-run in one process (LSP,
190
+ # ADR-62 warm loop) cannot hit stale entries.
191
+ def ancestor_constant_scopes(class_name, scope)
192
+ # ADR-46: `superclass_of` / `includes_of` record a cross-file class dependency per consumer
193
+ # file, and the memo is run-scoped rather than file-scoped — a hit would skip the recording and
194
+ # under-record the edge for every later file. Recording runs are rare (incremental only), so
195
+ # they simply bypass the memo rather than complicate its key.
196
+ return compute_ancestor_constant_scopes(class_name, scope) if Analysis::DependencyRecorder.active?
197
+
198
+ generation = scope.run_generation || scope.discovered_superclasses
199
+ slot = Thread.current[ANCESTOR_SCOPES_KEY]
200
+ unless slot && slot[0].equal?(generation)
201
+ slot = [generation, {}]
202
+ Thread.current[ANCESTOR_SCOPES_KEY] = slot
138
203
  end
139
- nil
204
+ bucket = slot[1]
205
+ bucket.fetch(class_name) { bucket[class_name] = compute_ancestor_constant_scopes(class_name, scope) }
140
206
  end
207
+ private_class_method :ancestor_constant_scopes
141
208
 
142
- # The candidate qualified names to try, in Ruby's lexical order: most-qualified first (the
143
- # enclosing class path joined to `name`), then progressively less-qualified, then the bare
144
- # `name`. A top-level scope (no `self_type`) yields only `[name]`.
145
- def lexical_constant_candidates(name, scope: Scope.empty)
146
- prefix = enclosing_class_path(scope)
147
- candidates = []
148
- while prefix && !prefix.empty?
149
- candidates << "#{prefix}::#{name}"
150
- idx = prefix.rindex("::")
151
- prefix = idx ? prefix[0, idx] : nil
209
+ def compute_ancestor_constant_scopes(class_name, scope)
210
+ queue = [class_name]
211
+ seen = { class_name => true }
212
+ out = []
213
+ until queue.empty?
214
+ current = queue.shift
215
+ # Mixins first, then the superclass — Ruby's ancestor order.
216
+ scope.includes_of(current).each do |raw|
217
+ resolved = resolve_ancestor_name(current, raw, scope)
218
+ next if resolved.nil? || seen[resolved]
219
+
220
+ seen[resolved] = true
221
+ out << resolved
222
+ queue << resolved
223
+ end
224
+ raw_super = scope.superclass_of(current)
225
+ next if raw_super.nil?
226
+
227
+ resolved_super = resolve_ancestor_name(current, raw_super, scope)
228
+ next if resolved_super.nil? || seen[resolved_super]
229
+
230
+ seen[resolved_super] = true
231
+ out << resolved_super
232
+ queue << resolved_super
233
+ end
234
+ out.freeze
235
+ end
236
+ private_class_method :compute_ancestor_constant_scopes
237
+
238
+ # Resolves an ancestor name AS WRITTEN (`"Base"`, or a qualified `"A::B"`) against the
239
+ # subclass's lexical nesting, innermost first — the same walk
240
+ # `ExpressionTyper#compute_ancestor_class_name` performs for method lookup. Returns nil when no
241
+ # candidate names a discovered project class or module.
242
+ def resolve_ancestor_name(subclass_qualified, raw, scope)
243
+ segments = subclass_qualified.split("::")
244
+ (segments.length - 1).downto(0) do |i|
245
+ candidate = (segments[0, i] + [raw]).join("::")
246
+ return candidate if known_project_namespace?(candidate, scope)
152
247
  end
153
- candidates << name
154
- candidates
248
+ nil
249
+ end
250
+ private_class_method :resolve_ancestor_name
251
+
252
+ def known_project_namespace?(name, scope)
253
+ scope.discovered_superclasses.key?(name) ||
254
+ scope.discovered_includes.key?(name) ||
255
+ scope.discovered_classes.key?(name)
155
256
  end
156
- private_class_method :lexical_constant_candidates
257
+ private_class_method :known_project_namespace?
157
258
 
158
259
  # Pulls the enclosing qualified class name out of `scope.self_type` when one is set.
159
260
  # `Nominal[T]` and `Singleton[T]` both expose `class_name`. Returns nil at the top level.
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.3.3"
4
+ VERSION = "0.3.5"
5
5
  end
data/lib/rigor.rb CHANGED
@@ -32,6 +32,7 @@ require_relative "rigor/cache/rbs_class_ancestor_table"
32
32
  require_relative "rigor/cache/rbs_class_type_param_names"
33
33
  require_relative "rigor/cache/rbs_environment"
34
34
  require_relative "rigor/type_node"
35
+ require_relative "rigor/effects"
35
36
  require_relative "rigor/flow_contribution"
36
37
  require_relative "rigor/flow_contribution/fact"
37
38
  require_relative "rigor/flow_contribution/conflict"
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Actioncable < Rigor::Plugin::Base
8
+ # rigor-actioncable's effect contract (ADR-103 WD10; design note § 11.2; issue #387).
9
+ #
10
+ # A broadcast leaves the process — through Redis, through the async in-memory adapter, or through
11
+ # Solid Cable's database table, depending on `config/cable.yml`. Bare `io` is the only sound
12
+ # transport, and `rails.actioncable.broadcast` is what a reviewer means when they say "this model
13
+ # callback pushes to every connected browser".
14
+ #
15
+ # Turbo Streams' `broadcast_*` family rides the same rows: it is ActionCable underneath, and a
16
+ # `broadcast_replace_later_to` in a model callback is exactly the effect a policy on `app/models/**`
17
+ # exists to surface.
18
+ module Effects
19
+ CHANNEL = "ActionCable::Channel::Base"
20
+ SERVER = "ActionCable.server"
21
+ BROADCASTING = ["io", "rails.actioncable.broadcast"].freeze
22
+
23
+ # `Turbo::Broadcastable`'s class-body-declared broadcasts, called on a model instance.
24
+ TURBO_SELECTORS = %w[
25
+ broadcast_append_to broadcast_prepend_to broadcast_replace_to broadcast_update_to
26
+ broadcast_remove_to broadcast_before_to broadcast_after_to broadcast_action_to
27
+ broadcast_render_to broadcast_refresh_to
28
+ ].freeze
29
+
30
+ # The `_later` twins enqueue an ActiveJob that broadcasts, so they carry the enqueue meaning too.
31
+ TURBO_LATER_SELECTORS = TURBO_SELECTORS.map { |name| "#{name.delete_suffix('_to')}_later_to" }.freeze
32
+
33
+ LATER = (BROADCASTING + ["rails.activejob.enqueue", "job.enqueue"]).freeze
34
+
35
+ module_function
36
+
37
+ def attributions
38
+ channel_rows + server_rows + turbo_rows
39
+ end
40
+
41
+ def channel_rows
42
+ [
43
+ row(CHANNEL, :broadcast_to, BROADCASTING, singleton: true,
44
+ why: "publishes to the channel's stream — out of this process, through whatever " \
45
+ "`config/cable.yml` names"),
46
+ row(CHANNEL, :transmit, BROADCASTING,
47
+ why: "sends a message down this subscriber's own connection"),
48
+ row(CHANNEL, :stream_from, BROADCASTING,
49
+ why: "subscribes the connection to a stream, which registers with the pubsub adapter"),
50
+ row(CHANNEL, :stream_for, BROADCASTING, why: "the model-keyed `stream_from`")
51
+ ]
52
+ end
53
+
54
+ def server_rows
55
+ [
56
+ row(SERVER, :broadcast, BROADCASTING, why: "the bare pubsub publish"),
57
+ row("ActionCable.server.pubsub", :broadcast, BROADCASTING, why: "the adapter-level publish")
58
+ ]
59
+ end
60
+
61
+ def turbo_rows
62
+ TURBO_SELECTORS.map do |selector|
63
+ row("ActiveRecord::Base", selector, BROADCASTING,
64
+ why: "Turbo Streams broadcasts over ActionCable — a model callback that pushes HTML to " \
65
+ "every connected browser is exactly the effect an `app/models/**` policy names")
66
+ end +
67
+ TURBO_LATER_SELECTORS.map do |selector|
68
+ row("ActiveRecord::Base", selector, LATER,
69
+ why: "the `_later` twin enqueues a job that broadcasts, so it carries the enqueue " \
70
+ "meaning as well as the broadcast one")
71
+ end
72
+ end
73
+
74
+ def row(receiver, selector, labels, why:, singleton: false)
75
+ EffectAttribution.new(receiver: receiver, method: selector, labels: labels,
76
+ singleton: singleton, discharge: true, why: why)
77
+ end
78
+
79
+ def entry_points
80
+ [
81
+ EffectEntryPoints.new(
82
+ name: "rails-channels", globs: ["app/channels/**/*.rb"],
83
+ why: "channel callbacks — `subscribed`, `receive` and friends are invoked by the framework"
84
+ )
85
+ ]
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -5,6 +5,7 @@ require "rigor/plugin"
5
5
  require_relative "actioncable/channel_index"
6
6
  require_relative "actioncable/channel_discoverer"
7
7
  require_relative "actioncable/analyzer"
8
+ require_relative "actioncable/effects"
8
9
 
9
10
  module Rigor
10
11
  module Plugin
@@ -80,7 +81,12 @@ module Rigor
80
81
  param_types: [{ index: 0, type_name: "Hash" }]
81
82
  # return_type_name: nil — receive's return value is discarded by the framework dispatcher.
82
83
  )
83
- ]
84
+ ],
85
+ # ADR-103 WD10 (#387) — see {Effects} for what each row is and why.
86
+ effect_root: "rails",
87
+ effect_labels: ["rails.actioncable.broadcast"],
88
+ effect_attributions: Effects.attributions,
89
+ effect_entry_points: Effects.entry_points
84
90
  )
85
91
 
86
92
  # `watch:` covers every `.rb` file under the channel search paths so the cache invalidates when
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Actionmailer < Rigor::Plugin::Base
8
+ # rigor-actionmailer's effect contract (ADR-103 WD10; design note § 11.2; issue #387).
9
+ #
10
+ # ActionMailer is the clearest case in Rails of a lazy builder followed by a transport, and the
11
+ # design note's deferred-execution rule reads it straight off the syntax:
12
+ #
13
+ # UserMailer.welcome(user).deliver_now
14
+ # └──────── builder ─────┘└─ the effect ┘
15
+ #
16
+ # `UserMailer.welcome(u)` runs the mailer method and returns a `MessageDelivery` that has sent
17
+ # nothing. So it is an **edge** into `UserMailer#welcome` (whose own body may well read the
18
+ # database) and contributes no transport of its own. `deliver_now` is the send. `deliver_later`
19
+ # enqueues, which is ActiveJob's row plus the mail meaning — `email.send` still belongs there,
20
+ # because the mail WILL go out and a policy that forbids sending mail from a presenter means to
21
+ # forbid both spellings.
22
+ #
23
+ # The `on_result:` rows are what make this work on the idiom people actually write: the
24
+ # `MessageDelivery` in the middle has no type the project declares, but the class that produced it
25
+ # is written right there.
26
+ module Effects
27
+ BASE = "ActionMailer::Base"
28
+ DELIVERY = "ActionMailer::MessageDelivery"
29
+
30
+ NOW = ["io", "email.send", "rails.actionmailer.deliver"].freeze
31
+ LATER = ["io", "email.send", "rails.actionmailer.deliver", "rails.activejob.enqueue",
32
+ "job.enqueue"].freeze
33
+
34
+ module_function
35
+
36
+ def attributions
37
+ rows(:deliver_now, NOW,
38
+ why: "the SMTP / API round trip itself — `io` because the delivery method is configured " \
39
+ "per environment and is statically unknowable, `email.send` because that is what a " \
40
+ "policy names") +
41
+ rows(:deliver_now!, NOW, why: "`deliver_now!` is `deliver_now` ignoring the perform-deliveries " \
42
+ "setting; the same send") +
43
+ rows(:deliver_later, LATER,
44
+ why: "enqueues a delivery job: the enqueue rows, plus `email.send` — the mail goes out, " \
45
+ "and a policy forbidding mail from this layer means both spellings") +
46
+ rows(:deliver_later!, LATER, why: "`deliver_later!` raises rather than serialising a missing " \
47
+ "record; the same enqueue")
48
+ end
49
+
50
+ # Each selector twice: once on a `MessageDelivery` receiver the typer managed to name, and once on
51
+ # the RESULT of a call to the mailer class, which is the spelling in every Rails codebase.
52
+ def rows(selector, labels, why:)
53
+ [
54
+ EffectAttribution.new(receiver: DELIVERY, method: selector, labels: labels, discharge: true,
55
+ why: why),
56
+ EffectAttribution.new(receiver: BASE, method: selector, labels: labels, on_result: true,
57
+ discharge: true,
58
+ why: "#{why} Matched on the result of `UserMailer.welcome(u)`, whose " \
59
+ "MessageDelivery has no type the project declares.")
60
+ ]
61
+ end
62
+
63
+ def edges
64
+ [
65
+ EffectEdge.new(
66
+ receiver: BASE, target: :mailer_body,
67
+ why: "`UserMailer.welcome(u)` instantiates the mailer and runs `#welcome` — synchronously, " \
68
+ "in this process, before any delivery is attempted. The mailer body's own effects " \
69
+ "(the records it reads to build the mail) belong to the caller"
70
+ )
71
+ ]
72
+ end
73
+
74
+ def entry_points
75
+ [
76
+ EffectEntryPoints.new(
77
+ name: "rails-mailers", globs: ["app/mailers/**/*.rb"],
78
+ why: "mailer actions — invoked by the framework through the class-method mapping"
79
+ )
80
+ ]
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -5,6 +5,7 @@ require "rigor/plugin"
5
5
  require_relative "actionmailer/mailer_index"
6
6
  require_relative "actionmailer/mailer_discoverer"
7
7
  require_relative "actionmailer/analyzer"
8
+ require_relative "actionmailer/effects"
8
9
 
9
10
  module Rigor
10
11
  module Plugin
@@ -52,7 +53,13 @@ module Rigor
52
53
  "mailer_search_paths" => { kind: :array, default: ["app/mailers"] },
53
54
  "mailer_base_classes" => { kind: :array, default: %w[ApplicationMailer ActionMailer::Base] },
54
55
  "views_root" => { kind: :string, default: "app/views" }
55
- }
56
+ },
57
+ # ADR-103 WD10 (#387) — see {Effects} for what each row is and why.
58
+ effect_root: "rails",
59
+ effect_labels: ["rails.actionmailer.deliver"],
60
+ effect_attributions: Effects.attributions,
61
+ effect_edges: Effects.edges,
62
+ effect_entry_points: Effects.entry_points
56
63
  )
57
64
 
58
65
  # `watch:` covers every mailer class under `mailer_search_paths` AND every view template under