rigortype 0.3.3 → 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 (158) 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 +6 -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/rule_ids.rb +28 -2
  33. data/lib/rigor/analysis/incremental_session.rb +140 -6
  34. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  35. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  36. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  37. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  38. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  39. data/lib/rigor/analysis/rule_catalog.rb +158 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  41. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  42. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  43. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  44. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  45. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  46. data/lib/rigor/analysis/runner.rb +420 -10
  47. data/lib/rigor/analysis/worker_session.rb +58 -2
  48. data/lib/rigor/bleeding_edge.rb +22 -0
  49. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  50. data/lib/rigor/cli/check_command.rb +13 -1
  51. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  52. data/lib/rigor/cli/effects_command.rb +131 -0
  53. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  54. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  55. data/lib/rigor/cli/effects_renderer.rb +54 -0
  56. data/lib/rigor/cli/effects_report.rb +53 -0
  57. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  58. data/lib/rigor/cli/unused_command.rb +288 -0
  59. data/lib/rigor/cli.rb +16 -0
  60. data/lib/rigor/configuration/severity_profile.rb +23 -0
  61. data/lib/rigor/configuration.rb +296 -14
  62. data/lib/rigor/effects/attribution.rb +76 -0
  63. data/lib/rigor/effects/catalog.rb +275 -0
  64. data/lib/rigor/effects/collector.rb +182 -0
  65. data/lib/rigor/effects/config_envelopes.rb +185 -0
  66. data/lib/rigor/effects/discharge.rb +69 -0
  67. data/lib/rigor/effects/effect_table.rb +92 -0
  68. data/lib/rigor/effects/entry_points.rb +93 -0
  69. data/lib/rigor/effects/envelope.rb +86 -0
  70. data/lib/rigor/effects/envelope_check.rb +172 -0
  71. data/lib/rigor/effects/envelope_index.rb +157 -0
  72. data/lib/rigor/effects/file_collection.rb +160 -0
  73. data/lib/rigor/effects/framework_units.rb +167 -0
  74. data/lib/rigor/effects/identity.rb +104 -0
  75. data/lib/rigor/effects/label.rb +77 -0
  76. data/lib/rigor/effects/label_intent.rb +73 -0
  77. data/lib/rigor/effects/label_set.rb +136 -0
  78. data/lib/rigor/effects/liskov_check.rb +167 -0
  79. data/lib/rigor/effects/local_ownership.rb +132 -0
  80. data/lib/rigor/effects/method_key.rb +40 -0
  81. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  82. data/lib/rigor/effects/narrowing.rb +202 -0
  83. data/lib/rigor/effects/origin.rb +66 -0
  84. data/lib/rigor/effects/path_finder.rb +89 -0
  85. data/lib/rigor/effects/plugin_facts.rb +335 -0
  86. data/lib/rigor/effects/propagator.rb +275 -0
  87. data/lib/rigor/effects/registry.rb +193 -0
  88. data/lib/rigor/effects/scanner.rb +290 -0
  89. data/lib/rigor/effects/signature_sources.rb +74 -0
  90. data/lib/rigor/effects/snapshot.rb +380 -0
  91. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  92. data/lib/rigor/effects/summary.rb +154 -0
  93. data/lib/rigor/effects/taint_cause.rb +38 -0
  94. data/lib/rigor/effects/unit_scan.rb +572 -0
  95. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  96. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  97. data/lib/rigor/effects.rb +47 -0
  98. data/lib/rigor/environment/rbs_loader.rb +34 -0
  99. data/lib/rigor/environment.rb +8 -1
  100. data/lib/rigor/flow_contribution/element.rb +1 -0
  101. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  102. data/lib/rigor/flow_contribution/merger.rb +16 -1
  103. data/lib/rigor/flow_contribution.rb +20 -4
  104. data/lib/rigor/inference/expression_typer.rb +18 -4
  105. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  106. data/lib/rigor/inference/scope_indexer.rb +15 -3
  107. data/lib/rigor/plugin/base.rb +24 -0
  108. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  109. data/lib/rigor/plugin/effect_edge.rb +101 -0
  110. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  111. data/lib/rigor/plugin/first_party.rb +57 -0
  112. data/lib/rigor/plugin/isolation.rb +4 -1
  113. data/lib/rigor/plugin/manifest.rb +103 -3
  114. data/lib/rigor/plugin/registry.rb +48 -0
  115. data/lib/rigor/plugin.rb +1 -0
  116. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  117. data/lib/rigor/rbs_extended.rb +168 -0
  118. data/lib/rigor/reflection.rb +131 -30
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +1 -0
  121. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  122. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  123. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  124. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  125. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  126. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  127. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  128. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  132. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  134. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  135. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  136. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  137. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  139. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  140. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  141. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  142. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  143. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  144. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  145. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  146. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  147. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  148. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  149. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  150. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  151. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  152. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  153. data/sig/rigor/analysis/fact_store.rbs +1 -0
  154. data/sig/rigor/inference.rbs +2 -0
  155. data/sig/rigor/rbs_extended.rbs +6 -0
  156. data/sig/rigor.rbs +17 -1
  157. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  158. metadata +79 -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
@@ -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
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../scope"
6
+ require_relative "../type"
7
+ require_relative "scope_indexer"
8
+
9
+ module Rigor
10
+ module Inference
11
+ # Issue #352 / [ADR-17](../../../docs/adr/17-monkey-patch-pre-evaluation.md) — the constant half of the
12
+ # `pre_eval:` publication surface.
13
+ #
14
+ # ADR-17 slice 2 gave `pre_eval:` a project-wide **method** registry ({ProjectPatchedMethods}). A
15
+ # constant declared in the same file stayed invisible across the file boundary: `Scope#in_source_constants`
16
+ # is per-file and is deliberately not part of `Runner#project_scope_seed_tables`, so only class-shaped
17
+ # constants (a `class` / `module` declaration, or one of the four meta-new forms `ScopeIndexer`'s
18
+ # `record_class_new_constant_decl` promotes) ever crossed. `TIMEOUT = 30` in a listed file read as
19
+ # `Dynamic[top]` everywhere else.
20
+ #
21
+ # This collector closes that half. It walks the `pre_eval:` files with the SAME constant pre-pass the
22
+ # per-file path uses ({ScopeIndexer.build_in_source_constants}) and publishes the result — widened — into
23
+ # the project seed. Files not listed under `pre_eval:` are untouched, so the feature is opt-in by
24
+ # construction and its cost stays proportional to the listed file count (ADR-17 WD1's cost-bounded
25
+ # argument, which is also why slice 5's full-project two-pass stayed rejectable).
26
+ #
27
+ # ## The widening rule — why the published type is not the same-file type
28
+ #
29
+ # Same-file constant propagation is **value-pinned**: `42`, `"hello"`, a `Tuple`, a `HashShape`. Carrying
30
+ # that verbatim across a file boundary would land diagnostics in files whose author never opened the
31
+ # constant's definition — `CONFIG = { a: 1 }` arriving as a closed `HashShape` makes `CONFIG.fetch(:b)`
32
+ # fire, `ARR = [1, 2]` arriving as a `Tuple` routes through `ShapeDispatch` instead of the RBS overload a
33
+ # call site relied on. Today's cross-file `Dynamic[top]` is false-positive-free *by construction*, so
34
+ # precision here can only spend that budget; AGENTS.md § "Implementation Guidelines" ranks false positives
35
+ # above worst-case static reading, so this slice spends as little of it as possible.
36
+ #
37
+ # {.widen} therefore publishes the **erased class**, never the value:
38
+ #
39
+ # | Declared | Same file | Published cross-file |
40
+ # | --- | --- | --- |
41
+ # | `INT_LIT = 42` | `42` | `Integer` |
42
+ # | `STR_LIT = "hello"` | `"hello"` | `String` |
43
+ # | `ARR_LIT = [1, 2]` | `[1, 2]` (Tuple) | `Array` (raw, elements dropped) |
44
+ # | `HSH_LIT = { a: 1 }` | `{ a: 1 }` (HashShape) | `Hash` (raw, keys dropped) |
45
+ # | `ALIAS_CLS = String` | `singleton(String)` | `singleton(String)` |
46
+ # | `Nest::NESTED_INT = 7` | `7` | `Integer` |
47
+ #
48
+ # Anything the rule does not recognise **declines** — the name is simply not published and reads exactly
49
+ # as it does today. Declining is always the safe answer here, which is why the `else` arm is `nil` rather
50
+ # than a fallback. `Constant[nil]` declines on purpose: a `X = nil` at declaration position is a
51
+ # placeholder for a value assigned at runtime far more often than it is a genuine `NilClass`, and
52
+ # publishing `NilClass` project-wide would make every use of it a diagnostic.
53
+ #
54
+ # Value-pinning a *provably* frozen single-write literal cross-file is a strictly later question; it needs
55
+ # its own FP measurement and is deliberately not attempted here.
56
+ #
57
+ # ## The multi-file write rule — widen on conflict, all the way to `Dynamic[top]`
58
+ #
59
+ # Within one file, `ScopeIndexer#record_constant_write` unions repeated writes; that union is widened as a
60
+ # whole (`X = 1; X = "a"` in one file publishes `Integer | String`). ACROSS files the same union would be
61
+ # a type neither author can see, so the rule is **widen on conflict**: when two listed files publish the
62
+ # same qualified name with different widened types, the name is dropped from the table entirely and reads
63
+ # as `Dynamic[top]` — the widest type there is, and the one the name already had. Agreeing writes (`X = 1`
64
+ # here, `X = 2` there — both `Integer`) are not a conflict at all, which is the point of widening first:
65
+ # `1 | 2` is never produced.
66
+ #
67
+ # ## Ordering
68
+ #
69
+ # The published table seeds `Scope#in_source_constants`, which `Reflection.constant_type_at` consults
70
+ # AFTER the class registry and `discovered_classes`. A published entry can therefore never mask a
71
+ # class-shaped constant that already crossed, and the per-file table always wins over the seed (see
72
+ # {ScopeIndexer.index}'s merge) — same-file remains the most specific authority.
73
+ module PreEvalConstants
74
+ EMPTY = {}.freeze
75
+
76
+ module_function
77
+
78
+ # Collects and widens the constants every `pre_eval:` file declares.
79
+ #
80
+ # @param paths [Array<String>] absolute paths to the `pre_eval:` files that exist on disk.
81
+ # @param scope_builder [#call] `path -> Rigor::Scope`; the caller supplies a project-seeded, environment-
82
+ # bound scope so the rvalue typer resolves cross-file classes exactly as per-file analysis would.
83
+ # @param target_ruby [String, nil] the Prism parse version (`Configuration#target_ruby`).
84
+ # @param buffer [Rigor::Analysis::BufferBinding, nil] editor-mode binding; when set, a listed file that
85
+ # matches the in-flight buffer is read from its physical bytes.
86
+ # @return [Hash{String => Rigor::Type}] frozen qualified-name -> published type table.
87
+ def collect(paths:, scope_builder:, target_ruby: nil, buffer: nil)
88
+ published = {}
89
+ conflicted = {}
90
+ paths.each do |path|
91
+ file_constants(path, scope_builder: scope_builder, target_ruby: target_ruby, buffer: buffer)
92
+ .each { |name, type| merge_publication(published, conflicted, name, type) }
93
+ end
94
+ published.freeze
95
+ end
96
+
97
+ # Folds one declaration into the accumulator under the widen-on-conflict rule. A name that has already
98
+ # conflicted stays out for the rest of the collection — a later agreeing write must not resurrect it.
99
+ def merge_publication(published, conflicted, name, type)
100
+ return if conflicted.key?(name)
101
+
102
+ widened = widen(type)
103
+ return if widened.nil?
104
+
105
+ existing = published[name]
106
+ return published[name] = widened if existing.nil?
107
+ return if existing == widened
108
+
109
+ published.delete(name)
110
+ conflicted[name] = true
111
+ end
112
+ private_class_method :merge_publication
113
+
114
+ # The per-file constant table, typed through the same pre-pass the per-file path runs. Deliberately NOT
115
+ # a full `ScopeIndexer.index`: ADR-17 WD4 keeps the pre-eval pass a discovery walk, and the constant
116
+ # pre-pass (plus the declaration artifacts it needs to resolve in-file class references) is exactly the
117
+ # discovery facet this feature consumes. Fails soft to the empty table — a pre-eval file that cannot be
118
+ # read or parsed already surfaces a `pre-eval.parse-error` warning from {ProjectPatchedScanner}, and it
119
+ # must never break the run.
120
+ def file_constants(path, scope_builder:, target_ruby: nil, buffer: nil)
121
+ physical = buffer ? buffer.resolve(path) : path
122
+ parse_result = Prism.parse(File.read(physical), filepath: path, version: target_ruby)
123
+ return EMPTY unless parse_result.errors.empty?
124
+
125
+ root = parse_result.value
126
+ ScopeIndexer.build_in_source_constants(root, declaration_seeded_scope(root, scope_builder.call(path)))
127
+ rescue StandardError
128
+ EMPTY
129
+ end
130
+ private_class_method :file_constants
131
+
132
+ # Mirrors the head of {ScopeIndexer.index}: seed the file's own declaration overrides and discovered
133
+ # classes onto the project-seeded scope so a `CONST = SomeClassDefinedRightHere.new` rvalue types the
134
+ # same way it does during real analysis.
135
+ def declaration_seeded_scope(root, scope)
136
+ declared_types, discovered_classes = ScopeIndexer.build_declaration_artifacts(root)
137
+ scope.with_discovery(
138
+ scope.discovery.with(
139
+ declared_types: declared_types,
140
+ discovered_classes: scope.discovered_classes.merge(discovered_classes)
141
+ )
142
+ )
143
+ end
144
+ private_class_method :declaration_seeded_scope
145
+
146
+ # The publication widening. Returns the type to publish, or `nil` to decline (the name keeps today's
147
+ # `Dynamic[top]` cross-file reading). See the module doc for why declining is the safe default.
148
+ def widen(type)
149
+ case type
150
+ when Type::Constant then widen_constant(type)
151
+ when Type::Refined then widen(type.base)
152
+ when Type::IntegerRange then Type::Combinator.nominal_of("Integer")
153
+ when Type::Tuple then Type::Combinator.nominal_of("Array")
154
+ when Type::HashShape then Type::Combinator.nominal_of("Hash")
155
+ when Type::Nominal then type.type_args.empty? ? type : Type::Combinator.nominal_of(type.class_name)
156
+ when Type::Singleton then type
157
+ when Type::DataInstance, Type::StructInstance then nominal_for_class_name(type.class_name)
158
+ when Type::Union then widen_union(type)
159
+ end
160
+ end
161
+
162
+ # `Constant[v]` publishes `v`'s class. `nil` declines (see the module doc); so does a value whose class
163
+ # is anonymous, which has no name to publish under.
164
+ def widen_constant(type)
165
+ return nil if type.value.nil?
166
+
167
+ nominal_for_class_name(type.value.class.name)
168
+ end
169
+ private_class_method :widen_constant
170
+
171
+ def nominal_for_class_name(class_name)
172
+ return nil unless class_name.is_a?(String) && !class_name.empty?
173
+
174
+ Type::Combinator.nominal_of(class_name)
175
+ end
176
+ private_class_method :nominal_for_class_name
177
+
178
+ # A union publishes only when EVERY member widens: one unrecognised arm means the union's real extent is
179
+ # unknown, and a partial union would be narrower than the truth — the false-positive direction.
180
+ def widen_union(type)
181
+ widened = type.members.map { |member| widen(member) }
182
+ return nil if widened.any?(&:nil?)
183
+
184
+ Type::Combinator.union(*widened)
185
+ end
186
+ private_class_method :widen_union
187
+ end
188
+ end
189
+ end
@@ -88,10 +88,13 @@ module Rigor
88
88
  # program and types its rvalue under a scope that carries the surrounding qualified prefix as `self_type`, so
89
89
  # the rvalue typer sees in-class references resolve correctly. Multiple writes to the same qualified name union
90
90
  # via `Type::Combinator.union`.
91
+ # Issue #352 — the per-file table merges OVER whatever the base scope already published (the
92
+ # `pre_eval:` constant seed `Runner#project_scope_seed_tables` applies). Same-file declarations are the
93
+ # most specific authority, exactly as `merged_classes` above resolves the same collision. Without the
94
+ # merge, the assignment below would silently drop the project seed on every file.
91
95
  in_source_constants = build_in_source_constants(root, seeded_scope)
92
- seeded_scope = seeded_scope.with_discovery(
93
- seeded_scope.discovery.with(in_source_constants: in_source_constants)
94
- )
96
+ merged_constants = merge_seeded_constants(default_scope.in_source_constants, in_source_constants)
97
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(in_source_constants: merged_constants))
95
98
 
96
99
  # Slice 7 phase 12. In-source method discovery. Walks every class/module body for `Prism::DefNode` and
97
100
  # recognised `define_method` calls and records the introduced method names. `rigor check` consults the table to
@@ -1206,6 +1209,15 @@ module Rigor
1206
1209
  accumulator.freeze
1207
1210
  end
1208
1211
 
1212
+ # Issue #352 — folds the project-wide `pre_eval:` constant seed under this file's own table. Returns the
1213
+ # per-file table unchanged (same frozen object) when nothing was seeded, so a run without `pre_eval:`
1214
+ # constants allocates and compares exactly what it did before.
1215
+ def merge_seeded_constants(seeded, per_file)
1216
+ return per_file if seeded.nil? || seeded.empty?
1217
+
1218
+ seeded.merge(per_file).freeze
1219
+ end
1220
+
1209
1221
  def walk_constant_writes(node, qualified_prefix, default_scope, accumulator)
1210
1222
  return unless node.is_a?(Prism::Node)
1211
1223
 
@@ -710,6 +710,30 @@ module Rigor
710
710
  manifest.protocol_contracts
711
711
  end
712
712
 
713
+ # ADR-103 WD6 / WD10 (#387) — the plugin's effect contributions. Each defaults to the manifest field
714
+ # and each MAY be overridden to fold in per-project facts, exactly as {#protocol_contracts} may.
715
+ #
716
+ # {#effect_attributions} is the one that needs it: a Rails app's ActiveJob transport is whatever
717
+ # `config.active_job.queue_adapter` names, so rigor-activejob answers `io.db.write` for Solid Queue
718
+ # and `io.net` for Sidekiq by reading the project's own configuration. That read is a plugin-side I/O
719
+ # boundary read and must not happen on a run with effects off — {Registry#effect_contributions} is
720
+ # lazy for exactly that reason, and nothing asks unless the project has an `effects:` block.
721
+ def effect_attributions
722
+ manifest.effect_attributions
723
+ end
724
+
725
+ def effect_edges
726
+ manifest.effect_edges
727
+ end
728
+
729
+ def effect_labels
730
+ manifest.effect_labels
731
+ end
732
+
733
+ def effect_entry_points
734
+ manifest.effect_entry_points
735
+ end
736
+
713
737
  # ADR-7 § "Slice 6-A/6-B" — per-plugin {IoBoundary}. Memoised so the boundary's accumulated `FileEntry`
714
738
  # rows persist across producer invocations within the same plugin instance and feed cache invalidation
715
739
  # via `cache_for`.