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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "unknown_label_report"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # Turns the declarations a run carries into `effect.unknown-label` findings (ADR-103 WD1 / WD14;
8
+ # #384). Pure: it reads envelopes and config values and answers value objects, never diagnostics
9
+ # and never the filesystem — positioning the finding is the caller's job, because only the caller
10
+ # knows whether a `virtual:` buffer has to be mapped back to the `.rb` the author wrote in.
11
+ #
12
+ # Two producers today, one shape:
13
+ #
14
+ # - {.for_envelopes} — every `%a{pure}` / `%a{rigor:v1:effect …}` the envelope scanner read, in
15
+ # `.rbs` or through rbs-inline. One finding per (declaration, unrecognised token); a
16
+ # `def self?.x` member declares two keys off ONE annotation, so findings are deduplicated by
17
+ # where they were written rather than by which method they bind.
18
+ # - {.for_config} — a `.rigor.yml` label list: `effects.tolerated:`, `effects.labels:`, each
19
+ # `envelopes[].effect` and each `attribution:` value. The key path is a parameter rather than a
20
+ # literal precisely because there are four of them and each names a different place to edit.
21
+ module UnknownLabelCheck
22
+ # `location` is the `path:line` the declaration was written at, or nil for a config value —
23
+ # the caller resolves both. `spelling` is the author's own annotation text, which is also how a
24
+ # finding read out of a synthesized buffer is found again in the Ruby source.
25
+ Finding = Data.define(:token, :subject, :consequence, :location, :spelling, :report) do
26
+ def message = report.message(subject: subject, consequence: consequence)
27
+ end
28
+
29
+ ENVELOPE_CONSEQUENCE = "the annotation now bounds nothing"
30
+
31
+ module_function
32
+
33
+ # @param method_envelopes [Hash{String => Envelope}]
34
+ # @param class_envelopes [Hash{String => Envelope}]
35
+ # @param registry [Rigor::Effects::Registry]
36
+ # @return [Array<Finding>]
37
+ def for_envelopes(method_envelopes:, class_envelopes:, registry:)
38
+ seen = {}
39
+ declarations(method_envelopes, class_envelopes).each do |envelope|
40
+ envelope.unknown_labels.each do |token|
41
+ report = UnknownLabelReport.for(
42
+ token: token, registry: registry, siblings: envelope.declared_labels
43
+ )
44
+ next if report.nil?
45
+
46
+ seen[[envelope.location, envelope.spelling, token]] ||= envelope_finding(envelope, token, report)
47
+ end
48
+ end
49
+ seen.values
50
+ end
51
+
52
+ # @param labels [Array<String>] the list as written, in source order.
53
+ # @param key_path [String] the `.rigor.yml` key, for the message (`effects.tolerated`).
54
+ # @param consequence [String] what the degradation cost, for the message.
55
+ # @return [Array<Finding>]
56
+ def for_config(labels:, key_path:, consequence:, registry:)
57
+ tokens = Array(labels).map(&:to_s)
58
+ tokens.filter_map do |token|
59
+ report = UnknownLabelReport.for(token: token, registry: registry, siblings: tokens)
60
+ next if report.nil?
61
+
62
+ Finding.new(
63
+ token: token, subject: "`#{key_path}:` in .rigor.yml", consequence: consequence,
64
+ location: nil, spelling: nil, report: report
65
+ )
66
+ end
67
+ end
68
+
69
+ # Method-level declarations first, so a method that also carries a class-level bound reports
70
+ # against its own annotation rather than against the distributed one.
71
+ def declarations(method_envelopes, class_envelopes)
72
+ method_envelopes.values + class_envelopes.values
73
+ end
74
+
75
+ def envelope_finding(envelope, token, report)
76
+ Finding.new(
77
+ token: token, subject: "Effect envelope on #{envelope.owner_key}",
78
+ consequence: ENVELOPE_CONSEQUENCE, location: envelope.location,
79
+ spelling: envelope.spelling, report: report
80
+ )
81
+ end
82
+
83
+ private_class_method :declarations, :envelope_finding
84
+ end
85
+ end
86
+ end
@@ -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
@@ -13,6 +13,7 @@ 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"
@@ -1134,6 +1135,11 @@ module Rigor
1134
1135
  arg_types = call_arg_types(node)
1135
1136
  block_type = block_return_type_for(node, receiver, arg_types)
1136
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
+
1137
1143
  local_def_result = try_local_def_dispatch(node, receiver, arg_types)
1138
1144
  return local_def_result if local_def_result
1139
1145
 
@@ -1181,11 +1187,19 @@ module Rigor
1181
1187
  # semantic outcome, not a fail-soft compromise, so it MUST NOT record a tracer event.
1182
1188
  return inherit_receiver_origin(node) if receiver.is_a?(Type::Dynamic)
1183
1189
 
1184
- # ADR-24 slice 4a — this is the engine choke-point where an implicit-self call has exhausted every
1185
- # resolution tier (RBS dispatch + user-class ancestor walk) and falls through to `Dynamic[top]`. When
1186
- # the slice-4 recorder is active, capture the miss so a later slice's closed-class gate can flag it.
1187
- # 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)
1188
1201
  record_unresolved_self_call(node, receiver) if Analysis::SelfCallResolutionRecorder.active?
1202
+ Effects::Collector.record_unresolved(node, scope.source_path) if Effects::Collector.active?
1189
1203
 
1190
1204
  fallback_for(node, family: :prism)
1191
1205
  end