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
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "label_intent"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # One `effect.unknown-label` finding, rendered (ADR-103 WD1 / WD14; #384).
8
+ #
9
+ # It carries the spelling, the nearest recognised label and the retirement record, and knows how
10
+ # to say all three in one sentence. What it deliberately does NOT carry is where the label was
11
+ # written or what the surface was: an envelope in `.rbs`, an rbs-inline annotation in `.rb` and a
12
+ # `.rigor.yml` list all reduce to "a place named this label, and now it means nothing", so the
13
+ # caller supplies the `subject` and the `consequence` and this supplies the vocabulary judgment.
14
+ # That is the seam [#385](https://github.com/rigortype/rigor/issues/385)'s `envelopes[].effect`
15
+ # and `attribution:` values point at.
16
+ #
17
+ # {.for} answers `nil` unless {LabelIntent} says the spelling is evidently a label — the whole
18
+ # point of the diagnostic is that it fires where intent is evident and nowhere else.
19
+ class UnknownLabelReport < Data.define(:token, :suggestion, :retirement)
20
+ # @param token [String] the spelling as written.
21
+ # @param registry [Rigor::Effects::Registry, nil] the vocabulary after plugin load.
22
+ # @param siblings [Array<String>] the other tokens written alongside it.
23
+ # @return [UnknownLabelReport, nil]
24
+ def self.for(token:, registry:, siblings: [])
25
+ return nil unless LabelIntent.evident?(token, registry, siblings: siblings)
26
+
27
+ new(
28
+ token: token.to_s,
29
+ suggestion: registry.suggest(token.to_s),
30
+ retirement: Array(registry.retired(token.to_s)).map(&:to_s).freeze
31
+ )
32
+ end
33
+
34
+ # Whether the vocabulary once carried this spelling and moved it. A retirement outranks a
35
+ # nearest-neighbour guess: the registry KNOWS where this label went, so guessing would be
36
+ # strictly worse information.
37
+ def retired?
38
+ !retirement.nil? && !retirement.empty?
39
+ end
40
+
41
+ # The sentence a reader can act on. `subject` names the declaration ("Effect envelope on
42
+ # `Foo#bar`"), `consequence` says what the degradation cost ("the annotation now bounds
43
+ # nothing") — the fail-open reading stated out loud, because a silently-unbounded envelope is
44
+ # exactly what this diagnostic exists to make visible.
45
+ def message(subject:, consequence:)
46
+ "#{subject} names #{token}, which is not a known effect label#{qualifier}; #{consequence}."
47
+ end
48
+
49
+ private
50
+
51
+ def qualifier
52
+ return " (#{token} is retired; write #{retirement.join(' or ')} instead)" if retired?
53
+ return " (did you mean #{suggestion}?)" if suggestion
54
+
55
+ ""
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "effects/label"
4
+ require_relative "effects/label_set"
5
+ require_relative "effects/registry"
6
+ require_relative "effects/taint_cause"
7
+ require_relative "effects/attribution"
8
+ require_relative "effects/catalog"
9
+ require_relative "effects/collector"
10
+ require_relative "effects/config_envelopes"
11
+ require_relative "effects/discharge"
12
+ require_relative "effects/effect_table"
13
+ require_relative "effects/envelope"
14
+ require_relative "effects/envelope_check"
15
+ require_relative "effects/envelope_index"
16
+ require_relative "effects/file_collection"
17
+ require_relative "effects/liskov_check"
18
+ require_relative "effects/method_key"
19
+ require_relative "effects/origin"
20
+ require_relative "effects/propagator"
21
+ require_relative "effects/summary"
22
+
23
+ module Rigor
24
+ # The effect-label vocabulary (ADR-103; normative in
25
+ # `docs/type-specification/effect-labels.md`).
26
+ #
27
+ # Two layers live here. The label *language* — the grammar, the subsumption relation, the label
28
+ # sets a summary and an envelope are made of, the registry that says which spellings are
29
+ # recognised, and the closed enum of taint causes. And, on top of it, the observational machinery
30
+ # of #379: the {Collector} that records what the typer decided, the {Summary} / {FileCollection}
31
+ # values it produces, the built-in {Catalog}, and the {Propagator} that closes them into an
32
+ # {EffectTable}. On top of both sits the one thing here that DOES judge: an {Envelope} — an
33
+ # author-declared upper bound read off the project's RBS or written by convention in `.rigor.yml`
34
+ # ({ConfigEnvelopes}) — and the two checks that judge one: {EnvelopeCheck} compares a method against
35
+ # its own bound (`effect.envelope-exceeded`, #383 / #385) and {LiskovCheck} an override against the
36
+ # bound it inherits (`effect.liskov-widened`, #386). The same declarations are read a third way, which
37
+ # judges nothing: {EnvelopeIndex} resolves them per CALL SITE, so a caller reads what its callee
38
+ # promised as a `≤` bound. Beside them sit the project's policy
39
+ # surfaces: {Attribution} colours code Rigor never analysed, into the declared lane, and {Discharge}
40
+ # applies `effects.tolerated:` per origin at judgment time. The effect snapshot (#381) and the
41
+ # vocabulary diagnostic (#384) are their own slices.
42
+ #
43
+ # "Effect label", "effect summary" and "effect envelope" are trapped compounds in `CONTEXT.md`;
44
+ # bare "effect" still names `Rigor::FlowContribution`'s flow-effect bundle.
45
+ module Effects
46
+ end
47
+ end
@@ -1256,6 +1256,40 @@ module Rigor
1256
1256
  end
1257
1257
  end
1258
1258
 
1259
+ # ADR-103 WD6 / #386 — yields `[class name, RBS::AST::Members::MethodDefinition]` for every method
1260
+ # member in the built environment that carries at least one annotation.
1261
+ #
1262
+ # The one reason this walk lives here rather than in its caller: `#env` is private, and it stays
1263
+ # private. The effect-envelope reader needs the *annotations* a gem's shipped signatures and
1264
+ # Rigor's bundled overlays declare — the accepted stratum of ADR-103 WD6, which discharges a call
1265
+ # site's taint because the same trust is already extended to those files' types — and nothing more
1266
+ # of the environment. Handing out the environment to get at them would trade a several-MB mutable
1267
+ # object for a read the loader can perform itself.
1268
+ #
1269
+ # Nested declarations are not descended into: `RBS::Environment` already flattens a nested
1270
+ # `class Foo::Bar` into its own `class_decls` entry, so a descent would key one member twice.
1271
+ #
1272
+ # Fail-soft, like every other query here: no environment (a build failure) yields nothing.
1273
+ def each_annotated_method_member
1274
+ environment = env
1275
+ return if environment.nil?
1276
+
1277
+ environment.class_decls.each do |type_name, entry|
1278
+ class_name = type_name.to_s.sub(/\A::/, "")
1279
+ self.class.entry_declarations(entry).each do |decl|
1280
+ members = decl.respond_to?(:members) ? decl.members : nil
1281
+ next if members.nil?
1282
+
1283
+ members.each do |member|
1284
+ next unless member.is_a?(::RBS::AST::Members::MethodDefinition)
1285
+ next if member.annotations.nil? || member.annotations.empty?
1286
+
1287
+ yield class_name, member
1288
+ end
1289
+ end
1290
+ end
1291
+ end
1292
+
1259
1293
  private
1260
1294
 
1261
1295
  # The `::`-stripped names of every class/module entry whose declarations ALL originated from the given
@@ -311,11 +311,18 @@ module Rigor
311
311
  # activesupport, the Rack status table) can then require the analyzed project's own locked gems when
312
312
  # Rigor's host gem environment does not carry them — the standalone `gem install rigortype` case,
313
313
  # where activesupport is deliberately not a runtime dependency.
314
+ # The hand-off is a module ivar, which a non-main Ractor may not write (`Ractor::IsolationError`) —
315
+ # and every Ractor-pool worker builds its Environment inside its own Ractor, so an unguarded write
316
+ # killed the whole backend at `WorkerSession#initialize` (#414). The resolution is deterministic per
317
+ # configuration and `Runner`'s pre-passes already perform it on the main Ractor before the pool
318
+ # spawns, so a worker reads the value the main set (a frozen String, hence shareable) and skips the
319
+ # write. The local return value is unaffected, so the missing-gem constant index still gets its root
320
+ # even when the write is skipped.
314
321
  def resolve_target_bundle_root(bundle_path:, project_root:, auto_detect:)
315
322
  bundle_root = BundleSigDiscovery.resolve_bundle_path(
316
323
  bundle_path: bundle_path, project_root: project_root, auto_detect: auto_detect
317
324
  )&.to_s
318
- Plugin::Isolation.target_bundle_root = bundle_root
325
+ Plugin::Isolation.target_bundle_root = bundle_root if Ractor.current == Ractor.main
319
326
  bundle_root
320
327
  end
321
328
 
@@ -21,6 +21,7 @@ module Rigor
21
21
  invalidation
22
22
  exception
23
23
  role
24
+ effects
24
25
  ].freeze
25
26
 
26
27
  class Element < Data.define(:target, :edge, :kind, :payload, :provenance)
@@ -13,13 +13,13 @@ module Rigor
13
13
  # for the slice-3 normative description.
14
14
  class MergeResult
15
15
  attr_reader :return_type, :truthy_facts, :falsey_facts, :post_return_facts,
16
- :mutations, :invalidations, :exceptional, :role_conformance,
16
+ :mutations, :invalidations, :exceptional, :role_conformance, :effects,
17
17
  :provenances, :conflicts
18
18
 
19
19
  # rubocop:disable Metrics/ParameterLists
20
20
  def initialize(return_type: nil, truthy_facts: [], falsey_facts: [],
21
21
  post_return_facts: [], mutations: [], invalidations: [],
22
- exceptional: nil, role_conformance: [],
22
+ exceptional: nil, role_conformance: [], effects: nil,
23
23
  provenances: [], conflicts: [])
24
24
  # rubocop:enable Metrics/ParameterLists
25
25
  @return_type = return_type
@@ -30,6 +30,7 @@ module Rigor
30
30
  @invalidations = invalidations.dup.freeze
31
31
  @exceptional = exceptional
32
32
  @role_conformance = role_conformance.dup.freeze
33
+ @effects = effects
33
34
  @provenances = provenances.dup.freeze
34
35
  @conflicts = conflicts.dup.freeze
35
36
  freeze
@@ -42,7 +43,7 @@ module Rigor
42
43
  def empty?
43
44
  @return_type.nil? && @truthy_facts.empty? && @falsey_facts.empty? &&
44
45
  @post_return_facts.empty? && @mutations.empty? && @invalidations.empty? &&
45
- @exceptional.nil? && @role_conformance.empty?
46
+ @exceptional.nil? && @role_conformance.empty? && @effects.nil?
46
47
  end
47
48
 
48
49
  def to_h
@@ -55,6 +56,7 @@ module Rigor
55
56
  "invalidations" => invalidations,
56
57
  "exceptional" => exceptional,
57
58
  "role_conformance" => role_conformance,
59
+ "effects" => effects,
58
60
  "provenances" => provenances.map { |p| p.respond_to?(:to_h) ? p.to_h : p },
59
61
  "conflicts" => conflicts.map(&:to_h)
60
62
  }
@@ -99,6 +99,7 @@ module Rigor
99
99
  fold_effects(state, contribution)
100
100
  fold_exceptional(state, contribution, tier)
101
101
  fold_role_conformance(state, contribution)
102
+ fold_effect_labels(state, contribution)
102
103
  end
103
104
 
104
105
  def fold_return_type(state, contribution, tier)
@@ -182,6 +183,18 @@ module Rigor
182
183
  accumulate(state.role_conformance, contribution.role_conformance)
183
184
  end
184
185
 
186
+ # ADR-103 WD5 — the `effects` slot merges by UNION, unconditionally and across every authority
187
+ # tier. A label set is an upper bound on what a call does, and two sources that each name part of
188
+ # a footprint together name more of it; there is no reading under which one source's claim
189
+ # cancels another's, so there is no conflict to report either. `nil` ("says nothing") stays nil
190
+ # until some contribution speaks.
191
+ def fold_effect_labels(state, contribution)
192
+ incoming = contribution.effects
193
+ return if incoming.nil?
194
+
195
+ state.effects = state.effects.nil? ? incoming : state.effects.join(incoming)
196
+ end
197
+
185
198
  def accumulate(target, incoming)
186
199
  Array(incoming).each do |item|
187
200
  target << item unless target.include?(item)
@@ -217,7 +230,7 @@ module Rigor
217
230
  # {MergeResult} at the end via {#to_result}.
218
231
  class MergeState
219
232
  attr_accessor :return_type, :return_type_tier, :return_type_provenance,
220
- :exceptional, :exceptional_tier, :exceptional_provenance
233
+ :exceptional, :exceptional_tier, :exceptional_provenance, :effects
221
234
  attr_reader :truthy_facts, :falsey_facts, :post_return_facts,
222
235
  :mutations, :invalidations, :role_conformance,
223
236
  :provenances, :conflicts
@@ -235,6 +248,7 @@ module Rigor
235
248
  @exceptional_tier = nil
236
249
  @exceptional_provenance = nil
237
250
  @role_conformance = []
251
+ @effects = nil
238
252
  @provenances = []
239
253
  @conflicts = []
240
254
  end
@@ -253,6 +267,7 @@ module Rigor
253
267
  invalidations: @invalidations,
254
268
  exceptional: @exceptional,
255
269
  role_conformance: @role_conformance,
270
+ effects: @effects,
256
271
  provenances: @provenances,
257
272
  conflicts: @conflicts
258
273
  )
@@ -9,7 +9,7 @@ module Rigor
9
9
  # [ADR-2 § "Plugin Contribution Merging"](../../docs/adr/2-extension-api.md) rather than letting any one
10
10
  # source override another silently.
11
11
  #
12
- # Eight content slots plus a {Provenance} block. A slot left as `nil` (or, for collection-shaped slots, an
12
+ # Nine content slots plus a {Provenance} block. A slot left as `nil` (or, for collection-shaped slots, an
13
13
  # empty collection) means the contribution does not assert anything in that dimension; the merge policy
14
14
  # treats it as absent.
15
15
  #
@@ -38,8 +38,15 @@ module Rigor
38
38
  invalidations
39
39
  exceptional
40
40
  role_conformance
41
+ effects
41
42
  ].freeze
42
43
 
44
+ # `effects` is scalar-shaped like `return_type` and `exceptional`: only `nil` means "asserts
45
+ # nothing". An EMPTY {Rigor::Effects::LabelSet} is the positive claim that the call performs no
46
+ # effects — the reading of `%a{pure}` — so it must not be mistaken for an unset slot the way an
47
+ # empty fact Array is.
48
+ SCALAR_SLOTS = %i[return_type exceptional effects].freeze
49
+
43
50
  attr_reader(*SLOT_NAMES, :provenance)
44
51
 
45
52
  # @param return_type [Object, nil] normal-edge return type. Use `nil` when the contribution does not
@@ -54,12 +61,17 @@ module Rigor
54
61
  # imply.
55
62
  # @param exceptional [Object, nil] non-returning, raising, or unreachable effect.
56
63
  # @param role_conformance [Array, nil] capability-role conformance facts the contribution provides.
64
+ # @param effects [Rigor::Effects::LabelSet, nil] ADR-103 WD5 — the effect labels this call edge
65
+ # attributes to its callee, as an upper bound. `nil` means "says nothing about effects", which is
66
+ # NOT the same as the empty set (which asserts the call performs none). Merged by union, the
67
+ # conservative direction: two sources that each name part of a call's footprint together name
68
+ # more of it, and neither can shrink the other's claim.
57
69
  # @param provenance [Provenance] source-family, plugin-id, node, and cache-descriptor metadata. Defaults
58
70
  # to `Provenance.builtin`.
59
71
  # rubocop:disable Metrics/ParameterLists
60
72
  def initialize(return_type: nil, truthy_facts: nil, falsey_facts: nil,
61
73
  post_return_facts: nil, mutations: nil, invalidations: nil,
62
- exceptional: nil, role_conformance: nil,
74
+ exceptional: nil, role_conformance: nil, effects: nil,
63
75
  provenance: Provenance.builtin)
64
76
  # rubocop:enable Metrics/ParameterLists
65
77
  @return_type = return_type
@@ -70,6 +82,7 @@ module Rigor
70
82
  @invalidations = freeze_collection(invalidations)
71
83
  @exceptional = exceptional
72
84
  @role_conformance = freeze_collection(role_conformance)
85
+ @effects = effects
73
86
  @provenance = provenance
74
87
  freeze
75
88
  end
@@ -77,7 +90,7 @@ module Rigor
77
90
  # @return [Boolean] true when every content slot is unset (nil or an empty collection). Provenance does
78
91
  # not count toward emptiness — an empty bundle still carries source attribution.
79
92
  def empty?
80
- SLOT_NAMES.all? { |slot| slot_empty?(public_send(slot)) }
93
+ SLOT_NAMES.all? { |slot| slot_empty?(slot, public_send(slot)) }
81
94
  end
82
95
 
83
96
  def to_h
@@ -102,6 +115,7 @@ module Rigor
102
115
  # | invalidations | normal | invalidation | (per-fact target) |
103
116
  # | exceptional | exceptional | exception | :raise |
104
117
  # | role_conformance | normal | role | (per-role target) |
118
+ # | effects | normal | effects | :effects |
105
119
  #
106
120
  # @return [Array<Element>]
107
121
  def to_element_list # rubocop:disable Metrics/AbcSize
@@ -116,6 +130,7 @@ module Rigor
116
130
  Array(invalidations).each { |i| elements << element_for(fact_target(i), :normal, :invalidation, i) }
117
131
  elements << element_for(:raise, :exceptional, :exception, exceptional) unless exceptional.nil?
118
132
  Array(role_conformance).each { |r| elements << element_for(fact_target(r), :normal, :role, r) }
133
+ elements << element_for(:effects, :normal, :effects, effects) unless effects.nil?
119
134
  elements.freeze
120
135
  end
121
136
 
@@ -149,8 +164,9 @@ module Rigor
149
164
  value.dup.freeze
150
165
  end
151
166
 
152
- def slot_empty?(value)
167
+ def slot_empty?(slot, value)
153
168
  return true if value.nil?
169
+ return false if SCALAR_SLOTS.include?(slot)
154
170
  return value.empty? if value.respond_to?(:empty?)
155
171
 
156
172
  false
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type/anonymous_class_name"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # #319 — identity for the class a `Class.new do ... end` / `Module.new do ... end` creates away from
10
+ # constant-write position.
11
+ #
12
+ # Ruby evaluates such a block with `self` bound to the freshly created class (`class_eval` semantics): a
13
+ # `def` inside it defines an instance method on that class and `attr_reader` runs as a class-level macro.
14
+ # `ScopeIndexer` has always walked that body as a class body — but only through its
15
+ # `Prism::ConstantWriteNode` branch, because the constant supplied the name the discovered-method tables
16
+ # are keyed by. At every other position (`klass = Class.new do ... end`, a bare expression, a method
17
+ # return) there was no name, so the body fell through to the enclosing — usually top-level — scope. Two
18
+ # false positives followed on correct code: `attr_reader` became a `call.unresolved-toplevel` warning
19
+ # (`Scope#toplevel?` is `self_type.nil?`), and the class itself typed as the bare `Singleton[Object]`
20
+ # `MethodDispatcher#class_new_lift` hands back, taking `Object`'s zero-arity `new` with it.
21
+ #
22
+ # This module supplies the missing name: a per-call-site synthetic one, spelled so that no Ruby constant
23
+ # path can collide with it in the discovery tables, and stable across the index pass and the dispatch
24
+ # pass because both derive it from the same `(call node, source path)` pair.
25
+ module AnonymousMetaClass
26
+ module_function
27
+
28
+ # The class-creating meta calls whose block body is a class body. `Struct.new` / `Data.define` are here
29
+ # because the body semantics are identical — the block is `class_eval`'d on the generated subclass — not
30
+ # because their anonymous *value* is modelled: those keep the `StructClass` / `DataClass` carriers
31
+ # {MethodDispatcher::StructFolding} / {MethodDispatcher::DataFolding} build for them.
32
+ META_NEW_SELECTORS = { Class: :new, Module: :new, Struct: :new, Data: :define }.freeze
33
+
34
+ # The receiver constant name (`:Class`, `:Module`, `:Struct`, `:Data`) of a class-creating meta call that
35
+ # carries a literal block, or nil for anything else. The receiver MUST be the bare constant (or its
36
+ # `::`-rooted spelling) — a call through a variable or another call's return has no statically known
37
+ # identity, exactly as `ScopeIndexer#meta_constant_receiver?` requires.
38
+ #
39
+ # Argument shapes are deliberately NOT inspected: `Class.new(Base) { ... }` and
40
+ # `Data.define(*members) { ... }` evaluate their block as a class body whatever the arguments are, and a
41
+ # narrower gate here would leave the body in top-level scope again.
42
+ def block_form_receiver(node)
43
+ return nil unless node.is_a?(Prism::CallNode)
44
+ return nil unless node.block.is_a?(Prism::BlockNode)
45
+
46
+ receiver_name = literal_constant_name(node.receiver)
47
+ return nil if receiver_name.nil?
48
+ return nil unless META_NEW_SELECTORS[receiver_name] == node.name
49
+
50
+ receiver_name
51
+ end
52
+
53
+ # The synthetic class name for `node`'s block body, or nil when `node` is not a recognised block form.
54
+ # {Type::AnonymousClassName} owns the spelling; this decides which call sites earn one and what the key
55
+ # holds.
56
+ #
57
+ # `source_path` is part of the key so two files whose anonymous classes happen to share a line and column
58
+ # do not merge their method sets when the per-file tables are folded into the cross-file seed. It is
59
+ # omitted when the caller has no path (unit-level probes over a bare `Prism` program) — passed through
60
+ # verbatim rather than defaulted, so every pass over the same file derives the same name.
61
+ def name_for(node, source_path = nil)
62
+ receiver_name = block_form_receiver(node)
63
+ return nil if receiver_name.nil?
64
+
65
+ location = node.location
66
+ key = [source_path, location.start_line, location.start_column].compact.join(":")
67
+ Type::AnonymousClassName.build(receiver_name, key)
68
+ end
69
+
70
+ # The bare-constant receiver's name (`Class`, `::Class`), or nil for every other receiver shape.
71
+ def literal_constant_name(node)
72
+ case node
73
+ when Prism::ConstantReadNode
74
+ node.name
75
+ when Prism::ConstantPathNode
76
+ node.name if node.parent.nil?
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -13,12 +13,15 @@ require_relative "body_fixpoint"
13
13
  require_relative "budget_trace"
14
14
  require_relative "dynamic_origin"
15
15
  require_relative "origin_lookup"
16
+ require_relative "../effects/collector"
16
17
  require_relative "fallback"
17
18
  require_relative "flow_tracer"
18
19
  require_relative "indexed_narrowing"
19
20
  require_relative "macro_block_self_type"
20
21
  require_relative "method_dispatcher"
21
22
  require_relative "narrowing"
23
+ require_relative "singleton_object_constant"
24
+ require_relative "optimistic_origin"
22
25
  require_relative "struct_fold_safety"
23
26
 
24
27
  module Rigor
@@ -609,16 +612,11 @@ module Rigor
609
612
  elide_or_union(node.predicate, else_type, then_type)
610
613
  end
611
614
 
612
- # Issue #286 — the effective optimistic-nil-free cause of an expression, resolving a bare local read (or
613
- # a local write in value position) through its binding. Mirrors `StatementEvaluator#optimistic_origin_for`.
615
+ # Issue #286 — the effective optimistic-nil-free cause of an expression. {OptimisticOrigin.resolve} owns
616
+ # the judgment, shared verbatim with `StatementEvaluator#optimistic_origin_for` and the
617
+ # `flow.always-truthy-condition` collector.
614
618
  def optimistic_origin_for(node)
615
- recorded = scope.optimistic_origins[node]
616
- return recorded if recorded
617
-
618
- case node
619
- when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.optimistic_local(node.name)
620
- when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.optimistic_ivar(node.name)
621
- end
619
+ OptimisticOrigin.resolve(node, scope)
622
620
  end
623
621
 
624
622
  def if_else_type(subsequent)
@@ -663,7 +661,7 @@ module Rigor
663
661
  # fallback is preserved.
664
662
  def type_of_and_or(node)
665
663
  left_type = type_of(node.left)
666
- polarity = constant_value_polarity(left_type)
664
+ polarity = left_operand_polarity(node.left, left_type)
667
665
  return short_circuit_for(node, left_type, polarity) if polarity
668
666
 
669
667
  # The left operand only flows through on the edge that short-circuits: `a || b` yields `a` solely
@@ -689,6 +687,19 @@ module Rigor
689
687
  end
690
688
  end
691
689
 
690
+ # Issue #313 — the node-aware wrapper the `&&` / `||` short-circuit reads. The spec's exclusion binds
691
+ # this gate as much as it binds `flow.always-truthy-condition`, and a `Constant`-only gate is not by
692
+ # itself enough to honour it: a literal hash whose values share one type reads as a lone `Constant`
693
+ # (`UNIFORM[key]` → `Constant[1]`), so the gate would judge the left operand of `UNIFORM[key] || key`
694
+ # provably truthy and discard the author's fallback — the counter-example the spec names verbatim.
695
+ # Declining returns the union of both operands, which is what `StatementEvaluator#eval_and_or` produces
696
+ # anyway, so the two `&&` / `||` typers stay in agreement.
697
+ def left_operand_polarity(left_node, left_type)
698
+ return nil unless optimistic_origin_for(left_node).nil?
699
+
700
+ constant_value_polarity(left_type)
701
+ end
702
+
692
703
  # Returns `:truthy` / `:falsey` for a `Type::Constant`, nil otherwise. Mirrors
693
704
  # `constant_predicate_polarity` but operates on a typed value (already-type-of'd) rather than a Prism
694
705
  # node, so the same predicate analysis can be reused in both contexts.
@@ -1092,8 +1103,12 @@ module Rigor
1092
1103
  # through `Enumerable#select` / `Object#select` and the caller observes `Array[Elem]` instead of the
1093
1104
  # helper's actual return type. The check fires only for `node.receiver.nil?` (true implicit self), so
1094
1105
  # explicit-receiver dispatch is unaffected.
1106
+ #
1107
+ # Issue #316 — the lookup goes through the confidence-gated `Scope#bindable_top_level_def_for`, not the
1108
+ # raw table: inside a block whose `self` is unmodelled, a top-level `def` from ANOTHER file is not
1109
+ # evidence about which method the call reaches, so the bind is declined and the call widens.
1095
1110
  def try_local_def_dispatch(node, receiver, arg_types)
1096
- local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
1111
+ local_def = node.receiver.nil? ? scope.bindable_top_level_def_for(node.name) : nil
1097
1112
  return nil unless local_def
1098
1113
 
1099
1114
  local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
@@ -1120,6 +1135,11 @@ module Rigor
1120
1135
  arg_types = call_arg_types(node)
1121
1136
  block_type = block_return_type_for(node, receiver, arg_types)
1122
1137
 
1138
+ # ADR-103 WD13 — the effect collector's one hot-path site. Purely observational: it reads the
1139
+ # receiver type the typer has just computed and the `dynamic_origins` cause already recorded, and
1140
+ # asks nothing further of dispatch. Off (the default) this is one integer read.
1141
+ Effects::Collector.record_call(node, receiver, scope) if Effects::Collector.active?
1142
+
1123
1143
  local_def_result = try_local_def_dispatch(node, receiver, arg_types)
1124
1144
  return local_def_result if local_def_result
1125
1145
 
@@ -1159,7 +1179,7 @@ module Rigor
1159
1179
  # `module_function` body, re-type that body with the call args bound. Sits after the RBS dispatch
1160
1180
  # tier, so foreign / RBS-known singletons (`Math.sqrt`) keep their catalog answer; only
1161
1181
  # project-defined singleton methods reach here.
1162
- singleton_inference = try_singleton_method_inference(receiver, node, arg_types)
1182
+ singleton_inference = try_project_singleton_inference(receiver, node, arg_types)
1163
1183
  return singleton_inference if singleton_inference
1164
1184
 
1165
1185
  # Dynamic-origin propagation: when the receiver is Dynamic[T] and no positive rule resolves the call,
@@ -1167,11 +1187,19 @@ module Rigor
1167
1187
  # semantic outcome, not a fail-soft compromise, so it MUST NOT record a tracer event.
1168
1188
  return inherit_receiver_origin(node) if receiver.is_a?(Type::Dynamic)
1169
1189
 
1170
- # ADR-24 slice 4a — this is the engine choke-point where an implicit-self call has exhausted every
1171
- # resolution tier (RBS dispatch + user-class ancestor walk) and falls through to `Dynamic[top]`. When
1172
- # the slice-4 recorder is active, capture the miss so a later slice's closed-class gate can flag it.
1173
- # Off by default: `active?` is a plain integer read.
1190
+ unresolved_call_result(node, receiver)
1191
+ end
1192
+
1193
+ # The engine choke-point where a call has exhausted every resolution tier (RBS dispatch + user-class
1194
+ # ancestor walk) and falls through to `Dynamic[top]`. Two observational recorders read it, both a
1195
+ # plain integer read when inactive, and neither changes the answer:
1196
+ #
1197
+ # - ADR-24 slice 4a captures an implicit-self miss so a later slice's closed-class gate can flag it;
1198
+ # - ADR-103 WD13 retracts the effect collector's optimistic `resolved: true`, which is what separates
1199
+ # an implicit-self call the closed world has no definition for from an ordinary inherited one.
1200
+ def unresolved_call_result(node, receiver)
1174
1201
  record_unresolved_self_call(node, receiver) if Analysis::SelfCallResolutionRecorder.active?
1202
+ Effects::Collector.record_unresolved(node, scope.source_path) if Effects::Collector.active?
1175
1203
 
1176
1204
  fallback_for(node, family: :prism)
1177
1205
  end
@@ -1336,6 +1364,31 @@ module Rigor
1336
1364
  nil
1337
1365
  end
1338
1366
 
1367
+ # The project-side singleton-method band: a `Foo.bar` call resolved against a `class << …` / `def self.…`
1368
+ # body the project itself wrote. Which of the two tiers applies is decided by the receiver carrier —
1369
+ # `Singleton[Foo]` when the constant names a class or module, `Nominal[…]` when it holds an ordinary
1370
+ # object (#320) — so the two are mutually exclusive and consulting both is one resolution attempt.
1371
+ def try_project_singleton_inference(receiver, call_node, arg_types)
1372
+ try_singleton_method_inference(receiver, call_node, arg_types) ||
1373
+ try_singleton_object_constant_inference(receiver, call_node, arg_types)
1374
+ end
1375
+
1376
+ # #320 — resolves a call whose receiver is a constant holding an ordinary object with a `class << Const`
1377
+ # singleton body, re-typing that body with the call's argument types bound. `self` inside the body IS
1378
+ # that object, so the receiver carrier is passed through unchanged. Own-constant only, and only for a
1379
+ # name the project actually recorded — a miss degrades to today's `Dynamic[top]`, never a false
1380
+ # resolution. `Singleton` receivers never reach here: {#try_singleton_method_inference} owns them.
1381
+ def try_singleton_object_constant_inference(receiver, call_node, arg_types)
1382
+ return nil unless receiver.is_a?(Type::Nominal)
1383
+
1384
+ def_node = SingletonObjectConstant.def_node_for(call_node, receiver, call_node.name, scope)
1385
+ return nil if def_node.nil?
1386
+
1387
+ infer_user_method_return(def_node, receiver, arg_types)
1388
+ rescue StandardError
1389
+ nil
1390
+ end
1391
+
1339
1392
  # ADR-24 slice 2 — resolves `method_name` against `class_name`'s own `def`s, then walks the user-class
1340
1393
  # ancestor chain: included / prepended modules (transitive) and the superclass chain. RBS-known
1341
1394
  # ancestors are NOT walked here — the `MethodDispatcher` RBS tier runs before
@@ -2444,7 +2497,11 @@ module Rigor
2444
2497
  case block_arg
2445
2498
  when Prism::BlockNode
2446
2499
  bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_arg)
2447
- block_scope = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
2500
+ # Issue #316 mirrors `StatementEvaluator#build_block_entry_scope`: the block body's `self` is the
2501
+ # yielding method's business, so the return-typing pass must see the same unmodelled-self mark.
2502
+ block_scope = bindings.reduce(scope.entering_opaque_block) do |acc, (name, type)|
2503
+ acc.with_local(name, type)
2504
+ end
2448
2505
  block_scope = block_scope.with_self_type(narrowed_self_type) if narrowed_self_type
2449
2506
  type_block_body(block_arg, block_scope)
2450
2507
  when Prism::BlockArgumentNode