rigortype 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) 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 +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  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 +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
data/lib/rigor/scope.rb CHANGED
@@ -23,6 +23,7 @@ module Rigor
23
23
  :indexed_narrowings, :method_chain_narrowings,
24
24
  :declaration_sourced,
25
25
  :source_path, :discovery, :struct_fold_safe_locals,
26
+ :opaque_block_self,
26
27
  :dynamic_origins, :local_origins, :ivar_origins,
27
28
  :void_origins,
28
29
  :optimistic_origins, :optimistic_locals, :optimistic_ivars
@@ -147,6 +148,7 @@ module Rigor
147
148
  declaration_sourced: EMPTY_DECLARATION_SOURCED,
148
149
  source_path: nil,
149
150
  struct_fold_safe_locals: EMPTY_FOLD_SAFE,
151
+ opaque_block_self: false,
150
152
  dynamic_origins: {}.compare_by_identity,
151
153
  local_origins: EMPTY_ORIGINS,
152
154
  ivar_origins: EMPTY_ORIGINS,
@@ -168,6 +170,7 @@ module Rigor
168
170
  @declaration_sourced = declaration_sourced
169
171
  @source_path = source_path
170
172
  @struct_fold_safe_locals = struct_fold_safe_locals
173
+ @opaque_block_self = opaque_block_self
171
174
  @dynamic_origins = dynamic_origins
172
175
  @local_origins = local_origins
173
176
  @ivar_origins = ivar_origins
@@ -274,6 +277,21 @@ module Rigor
274
277
  rebuild(struct_fold_safe_locals: locals)
275
278
  end
276
279
 
280
+ # Issue #316 — marks a block body whose `self` Rigor does not model. Ruby gives a block no `self` of its
281
+ # own: the yielding method decides, and `instance_eval` / `instance_exec` (the mechanism behind every
282
+ # `self`-rebinding DSL — RSpec example groups, `Class.new { … }`, Rake, Sinatra) is indistinguishable from
283
+ # `Array#each` without knowing the callee. The flag is set at every block entry that leaves `self_type`
284
+ # unnarrowed and is inherited by every scope derived inside the block; it never leaks past the block,
285
+ # because `eval_call` returns the caller's scope unchanged.
286
+ def entering_opaque_block
287
+ return self if @opaque_block_self
288
+
289
+ rebuild(opaque_block_self: true)
290
+ end
291
+
292
+ # True when this scope sits inside a block whose `self` is unmodelled ({#entering_opaque_block}).
293
+ def opaque_block_self? = @opaque_block_self
294
+
277
295
  # True when `name`'s `Struct` member reads are fold-safe in this body (the local is provably never mutated /
278
296
  # aliased / escaped).
279
297
  def struct_fold_safe?(name)
@@ -352,8 +370,11 @@ module Rigor
352
370
  # whose type is an open-call-site *lower bound* — firing against a lower bound is a false positive by
353
371
  # construction (the ADR-67 WD1 reasoning at the parameter boundary, carried one hop into the body). The
354
372
  # distinct kind keeps the inferred-param sites separable from ADR-58's ivar-copy `:local` mark, which a
355
- # later un-guarding slice (WD6b) needs. `with_local` drops it on any flow-live rewrite of the local
356
- # (`drop_local_declaration_marks`), so only the pristine parameter binding carries it.
373
+ # later un-guarding slice (WD6b) needs and the two kinds behave OPPOSITELY on both axes: `:local` is
374
+ # dropped by `with_local` and intersected by `join`, while `:inferred_param` is sticky across `with_local`
375
+ # and unioned by `join`. See {#without_inferred_param_mark} below for the clearing contract, and
376
+ # `docs/internal-spec/inference-engine.md` § "Declaration-sourced provenance mark (ADR-58)" for the
377
+ # normative statement of both.
357
378
  def with_inferred_param_mark(name)
358
379
  rebuild(declaration_sourced: add_declaration_sourced(:inferred_param, name))
359
380
  end
@@ -505,6 +526,43 @@ module Rigor
505
526
  Inference::DefNodeResolver.resolve(entry)
506
527
  end
507
528
 
529
+ # Issue #316 — the CONFIDENCE-GATED companion of {#top_level_def_for}, and the only accessor the type
530
+ # inference may bind through. {#top_level_def_for} stays unrestricted because it also serves the
531
+ # *suppression* side (`call.unresolved-toplevel`, `call.undefined-method`): a name the project defines at
532
+ # the top level must never be reported as unresolved, whatever this gate decides.
533
+ #
534
+ # Returns nil — decline to bind, stay silent — when BOTH hold:
535
+ #
536
+ # 1. The call site sits inside a block whose `self` is unmodelled ({#opaque_block_self?}) and no narrowed
537
+ # `self_type` says otherwise. A top-level `def` is a private method on `Object`, so it is *callable*
538
+ # from any `self`; what the analyzer cannot see is whether the block's real `self` gained a PUBLIC
539
+ # same-named method by `include` / `extend`, which wins the MRO over the private `Object` def. RSpec's
540
+ # `output` / `include` / `match` matchers against a project's own `def output` are exactly this.
541
+ # 2. The `def` lives in a DIFFERENT file from the call site. Collocation is the evidence that the two
542
+ # belong to one lexical structure — the `RSpec.describe do; def helper; end; it { helper } end` case
543
+ # v0.0.3 A and #319 deliberately serve. Cross-file, the two share only a name.
544
+ #
545
+ # Both conditions are required, so a top-level helper called from genuine top-level code keeps resolving
546
+ # (cross-file included), and a helper defined beside its DSL-block call site keeps resolving too. When the
547
+ # project pre-pass recorded no source for the name, the file test cannot be answered and the historical
548
+ # bind is kept.
549
+ def bindable_top_level_def_for(method_name)
550
+ node = top_level_def_for(method_name)
551
+ return node if node.nil?
552
+ return node unless @opaque_block_self && @self_type.nil?
553
+
554
+ same_file_top_level_def?(method_name) ? node : nil
555
+ end
556
+
557
+ def same_file_top_level_def?(method_name)
558
+ key = Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
559
+ site = discovered_def_sources.dig(key, method_name.to_sym)
560
+ return true if site.nil? || @source_path.nil?
561
+
562
+ File.expand_path(site.sub(/:\d+\z/, "")) == File.expand_path(@source_path)
563
+ end
564
+ private :same_file_top_level_def?
565
+
508
566
  # ADR-46 slice 3 — a top-level (`def helper` outside any class) call has NO class ancestry to walk, so unlike
509
567
  # {#user_def_for} a miss here records no positive ancestry edge that would re-check the consumer when the
510
568
  # method later appears. Record the cross-file edge explicitly: the file defining the top-level method
@@ -756,6 +814,7 @@ module Rigor
756
814
  declaration_sourced: @declaration_sourced,
757
815
  source_path: @source_path,
758
816
  struct_fold_safe_locals: @struct_fold_safe_locals,
817
+ opaque_block_self: @opaque_block_self,
759
818
  dynamic_origins: @dynamic_origins,
760
819
  local_origins: @local_origins,
761
820
  ivar_origins: @ivar_origins,
@@ -774,6 +833,7 @@ module Rigor
774
833
  declaration_sourced: declaration_sourced,
775
834
  source_path: source_path,
776
835
  struct_fold_safe_locals: struct_fold_safe_locals,
836
+ opaque_block_self: opaque_block_self,
777
837
  dynamic_origins: dynamic_origins,
778
838
  local_origins: local_origins,
779
839
  ivar_origins: ivar_origins,
@@ -14,6 +14,14 @@ module Rigor
14
14
  #
15
15
  # Non-Prism children (literals embedded in node attributes, virtual nodes, or `nil` slots) are silently
16
16
  # skipped so callers can rely on every yielded value responding to the `Prism::Node` API.
17
+ #
18
+ # Issue #318 — a `Prism::DefinedNode`'s operand is never evaluated at runtime (`defined?` inspects the
19
+ # expression statically; it does not run it), so the walk yields the `DefinedNode` itself but does NOT
20
+ # descend into its `value` subtree. Every consumer of this walker treats a yielded node as "reachable,
21
+ # evaluated code" (mutation/break/return scans, the check-rules main-pass oracle, coverage and precision
22
+ # probes); walking into the operand would make them reason about code that can never run, which is
23
+ # exactly the false-positive class the issue reports (`defined?(@x) && ...` flagging a call that is
24
+ # actually inert).
17
25
  module NodeWalker
18
26
  module_function
19
27
 
@@ -30,6 +38,8 @@ module Rigor
30
38
  return unless node.is_a?(Prism::Node)
31
39
 
32
40
  yield node
41
+ return if node.is_a?(Prism::DefinedNode)
42
+
33
43
  node.rigor_each_child { |child| walk(child, &) }
34
44
  end
35
45
 
@@ -52,6 +62,8 @@ module Rigor
52
62
  return unless node.is_a?(Prism::Node)
53
63
 
54
64
  block.call(node, ancestors)
65
+ return if node.is_a?(Prism::DefinedNode)
66
+
55
67
  ancestors.push(node)
56
68
  node.rigor_each_child { |child| walk_with_ancestors(child, ancestors, &block) }
57
69
  ancestors.pop
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Type
5
+ # #319 — the spelling of a class that Ruby created without a name: the `Class.new do ... end` /
6
+ # `Module.new do ... end` form away from constant-write position, which owns whatever its block body defines
7
+ # but has no constant to be keyed by.
8
+ #
9
+ # `Inference::AnonymousMetaClass` decides WHICH call sites get one and what goes in the key; this module owns
10
+ # the spelling, because the two places a name reaches a human live here. `#<Label:key>` is deliberately
11
+ # unspellable as a Ruby constant path, so a synthetic name can never collide with a real class in the
12
+ # discovery tables.
13
+ #
14
+ # Both renderings drop the key. `describe` keeps only the label (`#<Class>`) because the key is a file
15
+ # position — reproducing it would pin every `assert_type` fixture and precision snapshot to a line number.
16
+ # `erase_to_rbs` answers `untyped`: the name is not valid RBS, and emitting it from `rigor sig-gen` would
17
+ # produce a signature file that does not parse.
18
+ module AnonymousClassName
19
+ module_function
20
+
21
+ PREFIX = "#<"
22
+
23
+ # `build("Class", "lib/a.rb:3:17") #=> "#<Class:lib/a.rb:3:17>"`
24
+ def build(label, key)
25
+ "#{PREFIX}#{label}:#{key}>"
26
+ end
27
+
28
+ def match?(class_name)
29
+ class_name.is_a?(String) && class_name.start_with?(PREFIX)
30
+ end
31
+
32
+ # The display form: the label alone, key dropped.
33
+ def display(class_name)
34
+ return class_name unless match?(class_name)
35
+
36
+ "#{PREFIX}#{class_name.delete_prefix(PREFIX).split(':', 2).first}>"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../trinary"
4
4
  require_relative "../value_semantics"
5
5
  require_relative "acceptance_router"
6
+ require_relative "anonymous_class_name"
6
7
  require_relative "plain_lattice"
7
8
 
8
9
  module Rigor
@@ -35,13 +36,15 @@ module Rigor
35
36
  end
36
37
 
37
38
  def describe(verbosity = :short)
38
- return class_name if type_args.empty?
39
+ rendered_name = AnonymousClassName.display(class_name)
40
+ return rendered_name if type_args.empty?
39
41
 
40
42
  rendered = type_args.map { |t| t.describe(verbosity) }.join(", ")
41
- "#{class_name}[#{rendered}]"
43
+ "#{rendered_name}[#{rendered}]"
42
44
  end
43
45
 
44
46
  def erase_to_rbs
47
+ return "untyped" if AnonymousClassName.match?(class_name)
45
48
  return class_name if type_args.empty?
46
49
 
47
50
  rendered = type_args.map(&:erase_to_rbs).join(", ")
@@ -3,6 +3,7 @@
3
3
  require_relative "../trinary"
4
4
  require_relative "../value_semantics"
5
5
  require_relative "acceptance_router"
6
+ require_relative "anonymous_class_name"
6
7
  require_relative "plain_lattice"
7
8
 
8
9
  module Rigor
@@ -26,10 +27,12 @@ module Rigor
26
27
  end
27
28
 
28
29
  def describe(_verbosity = :short)
29
- "singleton(#{class_name})"
30
+ "singleton(#{AnonymousClassName.display(class_name)})"
30
31
  end
31
32
 
32
33
  def erase_to_rbs
34
+ return "untyped" if AnonymousClassName.match?(class_name)
35
+
33
36
  "singleton(#{class_name})"
34
37
  end
35
38
 
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.2"
4
+ VERSION = "0.3.4"
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
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Actionpack < Rigor::Plugin::Base
8
+ # rigor-actionpack's effect contract (ADR-103 WD10 / WD14; design note § 11.2; issue #387).
9
+ #
10
+ # A controller action's effects are mostly writes to the response and to per-request state, and the
11
+ # vocabulary distinguishes them because a reviewer does: `rails.response.write` is "this action
12
+ # answers the request", `rails.session.write` is "this action changes who the user is logged in as".
13
+ #
14
+ # ## `mutate.self`, not `io`
15
+ #
16
+ # `render` and `redirect_to` do not write to a socket. They set `@_response_body` and the status on
17
+ # the controller instance; Rack writes it later, outside any project method. So the honest label is
18
+ # `mutate.self` plus the framework meaning — and an envelope forbidding `io` in a service object is
19
+ # not violated by a helper that happens to call `render_to_string`.
20
+ #
21
+ # `render` additionally keeps a **taint**: the template is not an effect unit yet (ADR-103 WD11 /
22
+ # issue #392), so what the view does is genuinely unknown and the summary says so rather than
23
+ # pretending the action stops at the `render` line.
24
+ #
25
+ # ## The self-path rows
26
+ #
27
+ # `session[:user_id] = id` is `[]=` on the result of a receiver-less `session`, and nothing types
28
+ # that result. The `self.session` spelling matches the receiver expression as written, scoped by
29
+ # `within:` to classes whose project ancestry reaches `ActionController::Base` — so a `session`
30
+ # method on some unrelated project class is not mistaken for this one.
31
+ module Effects
32
+ CONTROLLER = "ActionController::Base"
33
+
34
+ RESPONSE = ["mutate.self", "rails.response.write"].freeze
35
+ SESSION_WRITE = ["mutate", "rails.session.write"].freeze
36
+ SESSION_READ = ["io", "rails.session.read"].freeze
37
+ COOKIE_WRITE = ["mutate", "rails.cookie.write"].freeze
38
+ FLASH_WRITE = ["mutate", "rails.flash.write"].freeze
39
+
40
+ # Response writers that say everything about themselves.
41
+ RESPONSE_WRITERS = %w[redirect_to redirect_back redirect_back_or_to head].freeze
42
+
43
+ # The render family. Same labels, plus the `template-not-analysed` taint: what the controller does
44
+ # is fully stated, and what the TEMPLATE does is unknown until views are effect units (ADR-103
45
+ # WD11 / issue #392). A summary that stopped at the `render` line and read exhaustive would be
46
+ # the one genuinely misleading row in the whole Rails layer.
47
+ RENDERERS = %w[render render_to_string render_to_body].freeze
48
+
49
+ # The cookie jars a Rails app writes through.
50
+ COOKIE_JARS = ["self.cookies", "self.cookies.signed", "self.cookies.encrypted",
51
+ "self.cookies.permanent"].freeze
52
+
53
+ module_function
54
+
55
+ def attributions
56
+ response_rows + file_rows + session_rows + cookie_rows + flash_rows
57
+ end
58
+
59
+ def response_rows
60
+ RESPONSE_WRITERS.map do |selector|
61
+ EffectAttribution.new(
62
+ receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true,
63
+ why: "sets the response on the controller instance — Rack writes the socket later, outside " \
64
+ "any project method, so this is `mutate.self` and deliberately not `io`"
65
+ )
66
+ end + render_rows
67
+ end
68
+
69
+ def render_rows
70
+ RENDERERS.map do |selector|
71
+ EffectAttribution.new(
72
+ receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true,
73
+ taint: "template-not-analysed",
74
+ why: "sets the response body from a template. The controller half is fully stated; the " \
75
+ "template's own effects are unknown until views become effect units, and the taint " \
76
+ "is how the summary says so rather than reading exhaustive"
77
+ )
78
+ end
79
+ end
80
+
81
+ # `send_file` streams from disk; `send_data` does not.
82
+ def file_rows
83
+ [
84
+ EffectAttribution.new(
85
+ receiver: CONTROLLER, method: :send_data, labels: RESPONSE, discharge: true,
86
+ why: "sets the response body from an in-memory string"
87
+ ),
88
+ EffectAttribution.new(
89
+ receiver: CONTROLLER, method: :send_file, labels: RESPONSE + ["io.fs.read"], discharge: true,
90
+ why: "sets the response AND reads the named file off disk"
91
+ )
92
+ ]
93
+ end
94
+
95
+ def session_rows
96
+ [
97
+ EffectAttribution.new(
98
+ receiver: "self.session", method: :[]=, labels: SESSION_WRITE, within: CONTROLLER,
99
+ discharge: true,
100
+ why: "writes per-request state whose store may be a cookie, a cache or the database"
101
+ ),
102
+ EffectAttribution.new(
103
+ receiver: "self.session", method: :delete, labels: SESSION_WRITE, within: CONTROLLER,
104
+ discharge: true, why: "same store, same write"
105
+ ),
106
+ EffectAttribution.new(
107
+ receiver: "self.session", method: :[], labels: SESSION_READ, within: CONTROLLER,
108
+ discharge: true,
109
+ why: "reads the session store — `io` because a cache- or database-backed store really does " \
110
+ "go out to fetch it"
111
+ ),
112
+ EffectAttribution.new(
113
+ receiver: CONTROLLER, method: :reset_session, labels: SESSION_WRITE, discharge: true,
114
+ why: "discards the whole session — the logout write"
115
+ )
116
+ ]
117
+ end
118
+
119
+ def cookie_rows
120
+ COOKIE_JARS.flat_map do |jar|
121
+ %i[[]= delete].map do |selector|
122
+ EffectAttribution.new(
123
+ receiver: jar, method: selector, labels: COOKIE_WRITE, within: CONTROLLER, discharge: true,
124
+ why: "queues a Set-Cookie header on the response — state that outlives the request"
125
+ )
126
+ end
127
+ end
128
+ end
129
+
130
+ def flash_rows
131
+ ["self.flash", "self.flash.now"].flat_map do |jar|
132
+ %i[[]= alert= notice=].map do |selector|
133
+ EffectAttribution.new(
134
+ receiver: jar, method: selector, labels: FLASH_WRITE, within: CONTROLLER, discharge: true,
135
+ why: "writes the flash, which rides the session into the next request"
136
+ )
137
+ end
138
+ end
139
+ end
140
+
141
+ def entry_points
142
+ [
143
+ EffectEntryPoints.new(
144
+ name: "rails-controllers", globs: ["app/controllers/**/*.rb"],
145
+ why: "controller actions — the request entry points; nothing in the project calls them"
146
+ )
147
+ ]
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -3,6 +3,7 @@
3
3
  require "rigor/plugin"
4
4
 
5
5
  require_relative "actionpack/analyzer"
6
+ require_relative "actionpack/effects"
6
7
  require_relative "actionpack/controller_discoverer"
7
8
  require_relative "actionpack/controller_index"
8
9
 
@@ -69,7 +70,14 @@ module Rigor
69
70
  consumes: [
70
71
  { plugin_id: "rails-routes", name: :helper_table, optional: true },
71
72
  { plugin_id: "activerecord", name: :model_index, optional: true }
72
- ]
73
+ ],
74
+ # ADR-103 WD10 / WD14 (#387) — see {Effects} for what each row is and why.
75
+ effect_root: "rails",
76
+ effect_labels: %w[
77
+ rails.response.write rails.session.read rails.session.write rails.cookie.write rails.flash.write
78
+ ],
79
+ effect_attributions: Effects.attributions,
80
+ effect_entry_points: Effects.entry_points
73
81
  )
74
82
 
75
83
  # Phase 2 cached producer — the controller index built from `controller_search_paths`. `watch:`