rigortype 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
data/lib/rigor/plugin.rb CHANGED
@@ -15,6 +15,7 @@ require_relative "plugin/load_error"
15
15
  require_relative "plugin/box"
16
16
  require_relative "plugin/isolation"
17
17
  require_relative "plugin/inflector"
18
+ require_relative "plugin/first_party"
18
19
 
19
20
  module Rigor
20
21
  module Plugin
@@ -4,6 +4,7 @@ require "tempfile"
4
4
 
5
5
  require_relative "../analysis/buffer_binding"
6
6
  require_relative "../analysis/runner"
7
+ require_relative "../inference/fork_map"
7
8
  require_relative "diagnostic_oracle"
8
9
  require_relative "discovery_seed"
9
10
  require_relative "kill_signature"
@@ -111,6 +112,18 @@ module Rigor
111
112
  [path, *(@dependents[path] || [])]
112
113
  end
113
114
 
115
+ # Release the process-private mutant file now, instead of waiting for `Tempfile`'s finalizer at process
116
+ # exit. A one-shot `rigor coverage` run does not need this — the process ends and the file goes with it —
117
+ # but a caller that outlives the oracle does: the spec suite's `after(:suite)` residue check (issue #330)
118
+ # runs while the process is still alive, so an un-released file reads as a leak there even though it
119
+ # would have been reclaimed a moment later. Idempotent, and safe on an oracle that never mutated
120
+ # anything (the file is created lazily).
121
+ def release!
122
+ @mutant_file&.close!
123
+ @mutant_file = nil
124
+ @mutant_pid = nil
125
+ end
126
+
114
127
  private
115
128
 
116
129
  # The diagnostic signatures the dependents of `path` report while `source` stands in for it. Empty (and
@@ -136,11 +149,17 @@ module Rigor
136
149
 
137
150
  # The process-private mutant file. Created lazily, and re-created after a fork ({CLI::MutationForkScan}
138
151
  # workers must not share one path), which the pid guard detects.
152
+ #
153
+ # A worker's copy goes in {Inference::ForkMap.child_scratch_dir} rather than the system temp dir: the
154
+ # worker ends at `exit!`, which runs neither `at_exit` nor `Tempfile`'s finalizer, so a file placed
155
+ # anywhere else survives the run with nobody left who knows its name (issue #330). On the parent — and
156
+ # on the sequential path — that reader answers `nil`, which is `Tempfile.new`'s own default, and the
157
+ # finalizer reclaims the file at normal exit as before.
139
158
  def mutant_file
140
159
  return @mutant_file if @mutant_file && @mutant_pid == Process.pid
141
160
 
142
161
  @mutant_pid = Process.pid
143
- @mutant_file = Tempfile.new(["rigor-mutant-", ".rb"])
162
+ @mutant_file = Tempfile.new(["rigor-mutant-", ".rb"], Inference::ForkMap.child_scratch_dir)
144
163
  end
145
164
 
146
165
  def seed_for(buffer)
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs"
4
+
5
+ require_relative "../rbs_extended"
6
+ require_relative "reporter"
7
+
8
+ module Rigor
9
+ module RbsExtended
10
+ # Collects every effect envelope the project's own RBS declares (ADR-103 WD5 / WD14; #383).
11
+ #
12
+ # It produces values, never diagnostics — the check that compares a bound against the proven
13
+ # summaries is {Rigor::Effects::EnvelopeCheck}. Two tables come out, because they are consumed
14
+ # differently:
15
+ #
16
+ # - {Result#method_envelopes} — keyed by method key (`Class#m` / `Class.m`), applied as written.
17
+ # - {Result#class_envelopes} — keyed by class / module name, DISTRIBUTED by the check to every
18
+ # method of that Ruby class discovery knows (reopenings, other files, synthesized `attr_*` and
19
+ # `define_method` members), never to subclasses. A per-method envelope wins over a distributed
20
+ # one; nearest wins, and no `-except` syntax is needed.
21
+ #
22
+ # ## Why it parses rather than reading the built environment
23
+ #
24
+ # {ConformanceChecker}, the obvious model, walks `RbsLoader`'s built env. An envelope cannot: the
25
+ # diagnostic has to name **where the bound was written** (`sig/foo.rbs:12`), and the ADR-54 env
26
+ # cache dumps every `RBS::Location` to a zero-range `<cached>` sentinel, so a warm run would lose
27
+ # both the position and the only evidence of which file a declaration came from. Parsing the
28
+ # project's own signature sources is a few milliseconds over a tree Rigor already globs, it is
29
+ # identical warm and cold, and it enforces ADR-103 WD6's trust rule structurally: a `%a{pure}` in
30
+ # rbs core or in a gem's shipped RBS is never read, so it cannot bound a project method that
31
+ # happens to share its key.
32
+ #
33
+ # Malformed payloads and `pure`-versus-`effect` contradictions are recorded on a {Reporter} the
34
+ # scanner owns and ride out on {Result#unresolved}. They surface no diagnostic in this slice:
35
+ # `effect.unknown-label` and the annotation-in-a-project-without-`effects:` residual are #384's.
36
+ module EnvelopeScanner
37
+ Result = Data.define(:method_envelopes, :class_envelopes, :unresolved) do
38
+ def empty? = method_envelopes.empty? && class_envelopes.empty?
39
+ end
40
+
41
+ EMPTY = Result.new(method_envelopes: {}.freeze, class_envelopes: {}.freeze, unresolved: [].freeze)
42
+
43
+ # RBS's kind for a `def self?.x` member — it declares BOTH sides, so the envelope binds both.
44
+ SINGLETON_INSTANCE = :singleton_instance
45
+ private_constant :SINGLETON_INSTANCE
46
+
47
+ module_function
48
+
49
+ # @param sources [Array<Array(String, String)>] `[buffer name, RBS source]` pairs — the project's
50
+ # `.rbs` files, plus the virtual entries rbs-inline and plugin `source_rbs` synthesis contribute.
51
+ # @param registry [Rigor::Effects::Registry] the vocabulary an unknown label is judged against.
52
+ # @return [Result]
53
+ def scan(sources:, registry:)
54
+ reporter = Reporter.new
55
+ methods = {}
56
+ classes = {}
57
+ sources.each do |name, content|
58
+ declarations(name, content).each do |decl|
59
+ walk(decl, [], methods, classes, registry, reporter)
60
+ end
61
+ end
62
+ Result.new(
63
+ method_envelopes: methods.freeze, class_envelopes: classes.freeze,
64
+ unresolved: reporter.unresolved_payloads
65
+ )
66
+ rescue StandardError
67
+ EMPTY
68
+ end
69
+
70
+ # The **accepted-signature** reader: every per-method envelope declared anywhere in a BUILT
71
+ # `RBS::Environment` — a gem's shipped signatures, Rigor's bundled overlays, core RBS
72
+ # (ADR-103 WD6; #386).
73
+ #
74
+ # It exists for one purpose and is usable for exactly one: importing a `≤` bound at a call site
75
+ # that resolves into un-analysed code ({Rigor::Effects::EnvelopeIndex}). It MUST NOT feed a
76
+ # contract check. Two facts make that a structural guarantee rather than a convention —
77
+ # {.scan}, which the check reads, never opens this door, and the values here carry no
78
+ # `location` at all (the ADR-54 env cache dumps every `RBS::Location` to a `<cached>` sentinel,
79
+ # so a warm run could not name where the bound was written even if a diagnostic wanted to).
80
+ # There is also no body to check: a gem method is un-analysed by definition, which is why WD6
81
+ # trusts the declaration instead, exactly as it already trusts the gem's *types*.
82
+ #
83
+ # Class-level annotations are deliberately not read. Distribution needs "every method of the
84
+ # class discovery knows", which is a project fact; a gem's class-level tag would have to
85
+ # distribute over a definition set this reader does not have.
86
+ #
87
+ # @param loader [Rigor::Environment::RbsLoader] the run's loader; it owns the env walk
88
+ # ({Rigor::Environment::RbsLoader#each_annotated_method_member}), so the environment itself never
89
+ # leaves it.
90
+ # @param registry [Rigor::Effects::Registry]
91
+ # @return [Hash{String => Rigor::Effects::Envelope}] keyed `Class#m` / `Class.m`
92
+ def from_loader(loader:, registry:)
93
+ out = {}
94
+ loader.each_annotated_method_member do |class_name, member|
95
+ keys_for(class_name, member).each do |key|
96
+ next if out.key?(key)
97
+
98
+ envelope = RbsExtended.read_effect_envelope(
99
+ member.annotations, owner_key: key, source: :effect_annotation, registry: registry
100
+ )
101
+ out[key] = envelope if envelope
102
+ end
103
+ end
104
+ out.freeze
105
+ rescue StandardError
106
+ {}.freeze
107
+ end
108
+
109
+ # Fail-soft per file: an unparseable `.rbs` is already quarantined by the loader and reported as
110
+ # `rbs.coverage.quarantined-signature`; contributing no envelope is the consistent answer here.
111
+ def declarations(name, content)
112
+ _, _, decls = ::RBS::Parser.parse_signature(::RBS::Buffer.new(name: name, content: content))
113
+ decls || []
114
+ rescue StandardError
115
+ []
116
+ end
117
+
118
+ # Descends the declaration tree, carrying the lexical prefix so a nested `class Bar` inside
119
+ # `module Foo` keys as `Foo::Bar` — the same spelling the effects scanner gives its units.
120
+ def walk(decl, prefix, methods, classes, registry, reporter)
121
+ return unless decl.is_a?(::RBS::AST::Declarations::Class) || decl.is_a?(::RBS::AST::Declarations::Module)
122
+
123
+ nested = prefix + [decl.name.to_s.sub(/\A::/, "")]
124
+ class_name = nested.join("::")
125
+ envelope = RbsExtended.read_effect_envelope(
126
+ decl.annotations, owner_key: class_name, source: :class_annotation,
127
+ registry: registry, reporter: reporter
128
+ )
129
+ classes[class_name] = envelope if envelope
130
+ decl.members.each { |member| absorb_member(member, nested, methods, classes, registry, reporter) }
131
+ end
132
+
133
+ def absorb_member(member, prefix, methods, classes, registry, reporter)
134
+ return walk(member, prefix, methods, classes, registry, reporter) unless
135
+ member.is_a?(::RBS::AST::Members::MethodDefinition)
136
+ return if member.annotations.nil? || member.annotations.empty?
137
+
138
+ keys_for(prefix.join("::"), member).each do |key|
139
+ envelope = RbsExtended.read_effect_envelope(
140
+ member.annotations, owner_key: key, source: :effect_annotation,
141
+ registry: registry, reporter: reporter
142
+ )
143
+ methods[key] = envelope if envelope
144
+ end
145
+ end
146
+
147
+ # The effect-unit keys one RBS member declares. `def self?.x` declares both sides, so its
148
+ # envelope binds both; every other kind declares one.
149
+ def keys_for(class_name, member)
150
+ case member.kind
151
+ when :singleton then ["#{class_name}.#{member.name}"]
152
+ when SINGLETON_INSTANCE then ["#{class_name}##{member.name}", "#{class_name}.#{member.name}"]
153
+ else ["#{class_name}##{member.name}"]
154
+ end
155
+ end
156
+
157
+ private_class_method :declarations, :walk, :absorb_member, :keys_for
158
+ end
159
+ end
160
+ end
@@ -3,6 +3,10 @@
3
3
  require_relative "type"
4
4
  require_relative "builtins/imported_refinements"
5
5
  require_relative "flow_contribution"
6
+ require_relative "effects/envelope"
7
+ require_relative "effects/label"
8
+ require_relative "effects/label_set"
9
+ require_relative "effects/signature_sources"
6
10
  require_relative "rbs_extended/reporter"
7
11
  require_relative "rbs_extended/hkt_directives"
8
12
 
@@ -621,6 +625,170 @@ module Rigor
621
625
  facts.empty? ? nil : facts
622
626
  end
623
627
 
628
+ # ── Effect envelopes (ADR-103 WD1 / WD5 / WD14; #383) ──────────────────────────────────────────
629
+ #
630
+ # Two spellings reach the same value. `%a{pure}` is the ecosystem's existing purity annotation
631
+ # (rbs core and Steep both carry it) and reads as the EMPTY envelope; `%a{rigor:v1:effect …}` is
632
+ # Rigor's labelled bound. `rigor:v1:pure` is deliberately NOT implemented — WD14 fixed `%a{pure}`
633
+ # as the only purity spelling.
634
+
635
+ # The bare rbs-native purity annotation. Matched whole, with surrounding whitespace tolerated, so
636
+ # `%a{purely}` and `%a{pure io}` are not it.
637
+ PURE_ANNOTATION_PATTERN = /\A\s*pure\s*\z/
638
+ private_constant :PURE_ANNOTATION_PATTERN
639
+
640
+ # `%a{rigor:v1:effect io.db, nondet.time}` — a space-separated head (the `assert` / `conforms-to`
641
+ # family), then a comma-separated list of BARE label tokens. There is no parenthesised comment
642
+ # form: RBS has real comments. The payload group is optional so that `%a{rigor:v1:effect}` still
643
+ # MATCHES the directive and is reported as malformed rather than read as "not a directive".
644
+ EFFECT_DIRECTIVE_PATTERN = /\Arigor:v1:effect(?:\s+(?<labels>.*?))?\s*\z/
645
+ private_constant :EFFECT_DIRECTIVE_PATTERN
646
+
647
+ # The outcome of reading one `%a{rigor:v1:effect …}` payload.
648
+ #
649
+ # `bound` is the declared {Rigor::Effects::LabelSet}, or {Effects::LabelSet::TOP} when the tag
650
+ # could not be given a meaning. `malformed` says the grammar was violated (an empty list, a token
651
+ # that is not a label); `unknown_labels` says the grammar held but the registry does not
652
+ # recognise a spelling — a different condition with different handling, and what #384's
653
+ # `effect.unknown-label` reads. `labels` is the list exactly as written, recognised or not, which
654
+ # is what lets the diagnostic ask whether some OTHER member of the list was known.
655
+ EffectAnnotation = Data.define(:bound, :labels, :unknown_labels, :malformed) do
656
+ def malformed? = malformed
657
+ def top? = bound.top?
658
+ end
659
+
660
+ NO_EFFECT_LABELS = [].freeze
661
+ private_constant :NO_EFFECT_LABELS
662
+
663
+ # Whether `string` is the bare `%a{pure}` annotation.
664
+ def pure_annotation?(string)
665
+ !string.nil? && PURE_ANNOTATION_PATTERN.match?(string)
666
+ end
667
+
668
+ # Reads one annotation string as an effect-envelope payload.
669
+ #
670
+ # Returns `nil` when the string is not a `rigor:v1:effect` directive at all (so a caller can walk
671
+ # an annotation list without pre-filtering). Otherwise an {EffectAnnotation}:
672
+ #
673
+ # - well-formed and fully recognised → the declared bound;
674
+ # - malformed (empty list, a token outside the label grammar) → {Effects::LabelSet::TOP}, and a
675
+ # `record_unresolved` event on `reporter`;
676
+ # - well-formed but carrying a spelling `registry` does not know → {Effects::LabelSet::TOP} and
677
+ # the unrecognised spellings, because an unknown label makes the WHOLE tag ⊤ rather than the
678
+ # subset that happened to parse. Narrowing to the recognised subset would turn a typo into
679
+ # findings on correct code; widening suppresses them, which is the direction the false-positive
680
+ # budget runs (ADR-5).
681
+ def parse_effect_annotation(string, registry: nil, reporter: nil, source_location: nil)
682
+ match = EFFECT_DIRECTIVE_PATTERN.match(string.to_s)
683
+ return nil if match.nil?
684
+
685
+ tokens = match[:labels].to_s.split(",", -1).map(&:strip)
686
+ if tokens.empty? || tokens.any? { |token| !Effects::Label.valid?(token) }
687
+ record_unresolved(reporter, string, source_location)
688
+ return EffectAnnotation.new(
689
+ bound: Effects::LabelSet::TOP, labels: NO_EFFECT_LABELS, unknown_labels: NO_EFFECT_LABELS, malformed: true
690
+ )
691
+ end
692
+
693
+ tokens = tokens.freeze
694
+ unknown = registry.nil? ? [] : tokens.reject { |token| registry.known?(token) }
695
+ EffectAnnotation.new(
696
+ bound: unknown.empty? ? Effects::LabelSet.new(tokens) : Effects::LabelSet::TOP,
697
+ labels: tokens, unknown_labels: unknown.uniq.sort.freeze, malformed: false
698
+ )
699
+ end
700
+
701
+ # Reads the effect envelope off an annotation list — `RBS::Definition::Method#annotations`, or the
702
+ # `#annotations` of an `RBS::AST::Members::MethodDefinition` / class declaration, which carry the
703
+ # same `(string, location)` shape.
704
+ #
705
+ # Precedence, per the spec: `%a{pure}` and `%a{rigor:v1:effect …}` on ONE declaration are
706
+ # contradictory and `pure` wins; the contradiction is recorded on `reporter` (the existing
707
+ # `RBS::Extended` conflict channel) rather than silently resolved. Returns `nil` when the list
708
+ # carries neither spelling.
709
+ #
710
+ # @param annotations [Array<#string>] the node's annotations, in source order.
711
+ # @param owner_key [String] the method key (`Class#m` / `Class.m`) or class name the bound binds.
712
+ # @param source [Symbol] {Effects::Envelope::SOURCES} member to stamp when the labelled spelling
713
+ # matched; `%a{pure}` always stamps `:pure_annotation`, and a class-level read overrides both.
714
+ def read_effect_envelope(annotations, owner_key:, source: :effect_annotation, registry: nil, reporter: nil)
715
+ return nil if annotations.nil? || annotations.empty?
716
+
717
+ pure = annotations.find { |annotation| pure_annotation?(annotation.string) }
718
+ labelled = nil
719
+ parsed = nil
720
+ annotations.each do |annotation|
721
+ result = parse_effect_annotation(
722
+ annotation.string, registry: registry, reporter: reporter, source_location: annotation_location(annotation)
723
+ )
724
+ next if result.nil?
725
+
726
+ labelled = annotation
727
+ parsed = result
728
+ break
729
+ end
730
+ return nil if pure.nil? && labelled.nil?
731
+
732
+ if pure && labelled
733
+ record_unresolved(
734
+ reporter,
735
+ "`%a{pure}` and `%a{#{labelled.string}}` on one declaration are contradictory; `pure` wins",
736
+ annotation_location(pure)
737
+ )
738
+ end
739
+ return build_pure_envelope(pure, owner_key: owner_key, source: source) if pure
740
+
741
+ build_effect_envelope(labelled, parsed, owner_key: owner_key, source: source)
742
+ end
743
+
744
+ def build_pure_envelope(annotation, owner_key:, source:)
745
+ Effects::Envelope.build(
746
+ owner_key: owner_key,
747
+ bound: Effects::LabelSet::EMPTY,
748
+ source: source == :class_annotation ? :class_annotation : :pure_annotation,
749
+ location: render_annotation_location(annotation),
750
+ spelling: "%a{#{annotation.string}}"
751
+ )
752
+ end
753
+
754
+ def build_effect_envelope(annotation, parsed, owner_key:, source:)
755
+ Effects::Envelope.build(
756
+ owner_key: owner_key,
757
+ bound: parsed.bound,
758
+ source: source,
759
+ location: render_annotation_location(annotation),
760
+ spelling: "%a{#{annotation.string}}",
761
+ unknown_labels: parsed.unknown_labels,
762
+ declared_labels: parsed.labels
763
+ )
764
+ end
765
+
766
+ def annotation_location(annotation)
767
+ annotation.respond_to?(:location) ? annotation.location : nil
768
+ end
769
+
770
+ # `path:line` for the annotation, project-relative when it sits under the working directory, so a
771
+ # diagnostic message names `sig/foo.rbs:12` rather than an absolute path.
772
+ def render_annotation_location(annotation)
773
+ location = annotation_location(annotation)
774
+ return nil if location.nil?
775
+
776
+ buffer = location.respond_to?(:buffer) ? location.buffer : nil
777
+ name = buffer.respond_to?(:name) ? buffer.name.to_s : nil
778
+ return nil if name.nil? || name.empty?
779
+
780
+ line = location.respond_to?(:start_line) ? location.start_line : 1
781
+ "#{relative_annotation_path(name)}:#{line}"
782
+ rescue StandardError
783
+ nil
784
+ end
785
+
786
+ # The buffer-name → readable-path rule lives with the walk that produces the buffers
787
+ # ({Effects::SignatureSources}), so a `virtual:` name is stripped identically wherever it surfaces.
788
+ def relative_annotation_path(name)
789
+ Effects::SignatureSources.source_path(name)
790
+ end
791
+
624
792
  # ADR-13 slice 3b — guards every reporter call so the in-RbsExtended-module call sites can record events
625
793
  # uniformly without nil-checking each time. When the reporter is nil (the v0.1.0 → v0.1.3 default for call
626
794
  # sites that do not yet thread `environment:`), the call is a no-op and the parser stays fail-soft.
@@ -44,6 +44,10 @@ module Rigor
44
44
  # out of scope for the v0.0.7 first pass; v0.1.0's plugin API added it as a separate
45
45
  # concern.
46
46
  module Reflection
47
+ # #354 — thread-local slot for the per-run ancestor-scope memo. See {.ancestor_constant_scopes}.
48
+ ANCESTOR_SCOPES_KEY = :__rigor_ancestor_constant_scopes__
49
+ private_constant :ANCESTOR_SCOPES_KEY
50
+
47
51
  module_function
48
52
 
49
53
  # @param class_name [String, Symbol]
@@ -118,42 +122,139 @@ module Rigor
118
122
  # reads it to type a constant read, and `Inference::Narrowing` reads it to recognise a
119
123
  # value-pinned `Constant[Regexp]` match-predicate operand.
120
124
  def resolve_constant_type(name, scope: Scope.empty)
125
+ prefix = enclosing_class_path(scope)
126
+
127
+ # Step 1 — `Module.nesting`, innermost first. Each entry contributes only its OWN constants.
128
+ walker = prefix
129
+ while walker && !walker.empty?
130
+ hit = constant_type_at("#{walker}::#{name}", scope)
131
+ return hit if hit
132
+
133
+ idx = walker.rindex("::")
134
+ walker = idx ? walker[0, idx] : nil
135
+ end
136
+
137
+ # Step 2 (#354) — the ancestors of the innermost cresting scope, which Ruby consults BEFORE
138
+ # falling back to the top level. Skipping this step did not merely lose a resolution: when the
139
+ # same name also exists at top level, step 3 answered a lookup Ruby gives to the ancestor, so
140
+ # `KEY` inside `class Sub < Base` typed as the top-level constant rather than `Base::KEY` — a
141
+ # wrong type on correct code, which outranks any worst-case reading (AGENTS.md
142
+ # § "Implementation Guidelines"). Only project ancestors are walked; an RBS-known superclass
143
+ # contributes no name here (see {.ancestor_constant_scopes}).
144
+ if prefix
145
+ ancestor_constant_scopes(prefix, scope).each do |ancestor|
146
+ hit = constant_type_at("#{ancestor}::#{name}", scope)
147
+ return hit if hit
148
+ end
149
+ end
150
+
151
+ # Step 3 — the bare name (top level).
152
+ constant_type_at(name, scope)
153
+ end
154
+
155
+ # One candidate name, consulted in source-precedence order: the class registry (yielding a
156
+ # `Singleton[C]`), source-discovered classes, in-source value constants, then RBS-side
157
+ # constants. In-source values win over RBS constant decls because the user's source is
158
+ # authoritative for its own constants. Returns nil when no source knows `candidate`.
159
+ def constant_type_at(candidate, scope)
121
160
  env = scope.environment
122
- discovered = scope.discovered_classes
123
- in_source = scope.in_source_constants
124
- lexical_constant_candidates(name, scope: scope).each do |candidate|
125
- singleton = env.singleton_for_name(candidate)
126
- return singleton if singleton
127
-
128
- in_source_class = discovered[candidate]
129
- return in_source_class if in_source_class
130
-
131
- # In-source value-bearing constants take precedence over RBS constant decls because
132
- # user code is the authoritative source for its own constants.
133
- in_source_value = in_source[candidate]
134
- return in_source_value if in_source_value
135
-
136
- value = env.constant_for_name(candidate)
137
- return value if value
161
+
162
+ singleton = env.singleton_for_name(candidate)
163
+ return singleton if singleton
164
+
165
+ in_source_class = scope.discovered_classes[candidate]
166
+ return in_source_class if in_source_class
167
+
168
+ in_source_value = scope.in_source_constants[candidate]
169
+ return in_source_value if in_source_value
170
+
171
+ env.constant_for_name(candidate)
172
+ end
173
+ private_class_method :constant_type_at
174
+
175
+ # #354 — the project classes and modules whose own constants `class_name` inherits, in Ruby's
176
+ # ancestor order: included / prepended modules before the superclass (Ruby places mixins nearer),
177
+ # transitively, breadth-first. `class_name` itself is excluded — step 1 already covered it.
178
+ #
179
+ # Only PROJECT ancestors appear. `Scope#superclass_of` / `#includes_of` carry as-written names
180
+ # from the discovery pre-pass, and an as-written name that resolves to no discovered class or
181
+ # module is dropped — so a `class Foo < ActiveRecord::Base` contributes nothing and a constant
182
+ # owned by an RBS-known ancestor still resolves only if the bare name reaches it at step 3. That
183
+ # gap is deliberate for this slice: widening to the RBS ancestor graph is a separate question
184
+ # with its own FP surface.
185
+ #
186
+ # Memoised per run because step 2 runs on every constant reference whose lexical candidates all
187
+ # miss — which is the common case for a core-class reference (`String` inside `class Foo`). The
188
+ # bucket keys on the identity of the runner-seeded run-generation token (ADR-84 WD2), falling
189
+ # back to the per-file discovery table for runner-less scopes, so a re-run in one process (LSP,
190
+ # ADR-62 warm loop) cannot hit stale entries.
191
+ def ancestor_constant_scopes(class_name, scope)
192
+ # ADR-46: `superclass_of` / `includes_of` record a cross-file class dependency per consumer
193
+ # file, and the memo is run-scoped rather than file-scoped — a hit would skip the recording and
194
+ # under-record the edge for every later file. Recording runs are rare (incremental only), so
195
+ # they simply bypass the memo rather than complicate its key.
196
+ return compute_ancestor_constant_scopes(class_name, scope) if Analysis::DependencyRecorder.active?
197
+
198
+ generation = scope.run_generation || scope.discovered_superclasses
199
+ slot = Thread.current[ANCESTOR_SCOPES_KEY]
200
+ unless slot && slot[0].equal?(generation)
201
+ slot = [generation, {}]
202
+ Thread.current[ANCESTOR_SCOPES_KEY] = slot
138
203
  end
139
- nil
204
+ bucket = slot[1]
205
+ bucket.fetch(class_name) { bucket[class_name] = compute_ancestor_constant_scopes(class_name, scope) }
140
206
  end
207
+ private_class_method :ancestor_constant_scopes
141
208
 
142
- # The candidate qualified names to try, in Ruby's lexical order: most-qualified first (the
143
- # enclosing class path joined to `name`), then progressively less-qualified, then the bare
144
- # `name`. A top-level scope (no `self_type`) yields only `[name]`.
145
- def lexical_constant_candidates(name, scope: Scope.empty)
146
- prefix = enclosing_class_path(scope)
147
- candidates = []
148
- while prefix && !prefix.empty?
149
- candidates << "#{prefix}::#{name}"
150
- idx = prefix.rindex("::")
151
- prefix = idx ? prefix[0, idx] : nil
209
+ def compute_ancestor_constant_scopes(class_name, scope)
210
+ queue = [class_name]
211
+ seen = { class_name => true }
212
+ out = []
213
+ until queue.empty?
214
+ current = queue.shift
215
+ # Mixins first, then the superclass — Ruby's ancestor order.
216
+ scope.includes_of(current).each do |raw|
217
+ resolved = resolve_ancestor_name(current, raw, scope)
218
+ next if resolved.nil? || seen[resolved]
219
+
220
+ seen[resolved] = true
221
+ out << resolved
222
+ queue << resolved
223
+ end
224
+ raw_super = scope.superclass_of(current)
225
+ next if raw_super.nil?
226
+
227
+ resolved_super = resolve_ancestor_name(current, raw_super, scope)
228
+ next if resolved_super.nil? || seen[resolved_super]
229
+
230
+ seen[resolved_super] = true
231
+ out << resolved_super
232
+ queue << resolved_super
233
+ end
234
+ out.freeze
235
+ end
236
+ private_class_method :compute_ancestor_constant_scopes
237
+
238
+ # Resolves an ancestor name AS WRITTEN (`"Base"`, or a qualified `"A::B"`) against the
239
+ # subclass's lexical nesting, innermost first — the same walk
240
+ # `ExpressionTyper#compute_ancestor_class_name` performs for method lookup. Returns nil when no
241
+ # candidate names a discovered project class or module.
242
+ def resolve_ancestor_name(subclass_qualified, raw, scope)
243
+ segments = subclass_qualified.split("::")
244
+ (segments.length - 1).downto(0) do |i|
245
+ candidate = (segments[0, i] + [raw]).join("::")
246
+ return candidate if known_project_namespace?(candidate, scope)
152
247
  end
153
- candidates << name
154
- candidates
248
+ nil
249
+ end
250
+ private_class_method :resolve_ancestor_name
251
+
252
+ def known_project_namespace?(name, scope)
253
+ scope.discovered_superclasses.key?(name) ||
254
+ scope.discovered_includes.key?(name) ||
255
+ scope.discovered_classes.key?(name)
155
256
  end
156
- private_class_method :lexical_constant_candidates
257
+ private_class_method :known_project_namespace?
157
258
 
158
259
  # Pulls the enclosing qualified class name out of `scope.self_type` when one is set.
159
260
  # `Nominal[T]` and `Singleton[T]` both expose `class_name`. Returns nil at the top level.