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,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,34 @@ 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
+
737
+ def effect_ancestry
738
+ manifest.effect_ancestry
739
+ end
740
+
713
741
  # ADR-7 § "Slice 6-A/6-B" — per-plugin {IoBoundary}. Memoised so the boundary's accumulated `FileEntry`
714
742
  # rows persist across producer invocations within the same plugin instance and feed cache invalidation
715
743
  # via `cache_for`.
@@ -32,14 +32,30 @@ module Rigor
32
32
  # Requires `feature` into the shared box exactly once. Returns true when the feature is available in the
33
33
  # box, false when it could not be loaded (the caller then declines — never falls back to loading into the
34
34
  # main space, which would defeat the boundary).
35
+ #
36
+ # The rescue is `::`-qualified and covers `::ScriptError`, matching {Isolation::Process.run_worker_loop}:
37
+ # a failed box require raises `::LoadError` — a ScriptError, not a StandardError — and a plain
38
+ # `rescue StandardError` lets it escape and abort the whole run instead of the clean decline.
35
39
  def require_feature(feature)
36
40
  @required ||= {}
37
41
  return @required[feature] if @required.key?(feature)
38
42
 
39
43
  shared.require(feature)
40
44
  @required[feature] = true
41
- rescue StandardError
42
- @required[feature] = false
45
+ rescue ::StandardError, ::ScriptError
46
+ @required[feature] = eval_require(feature)
47
+ end
48
+
49
+ # The gem-resolving fallback: `Ruby::Box#require` resolves against the raw `$LOAD_PATH` only (it
50
+ # calls the C-level require directly), so a target library installed as a gem is reachable only
51
+ # through the box's own RubyGems — the `Kernel#require` *inside* the box. `feature` is a fixed,
52
+ # plugin-declared name rendered via `String#inspect` (a safe Ruby literal), so the eval carries no
53
+ # free input. Returns false when the box cannot load the feature either way (the caller declines).
54
+ def eval_require(feature)
55
+ shared.eval("require #{feature.inspect}") # require "active_support/inflector"
56
+ true
57
+ rescue ::StandardError, ::ScriptError
58
+ false
43
59
  end
44
60
 
45
61
  # Evaluates `code` inside the shared box and returns the result across the box boundary. The caller MUST
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "effect_attribution"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ # One entry of a plugin's `effect_ancestry:` — an ancestry edge the plugin's own gem introduces and
8
+ # the project's source never writes down (ADR-103 WD17; [#465](https://github.com/rigortype/rigor/issues/465)).
9
+ #
10
+ # `Effects::PluginFacts` walks the project's own `class … <` and `include` lines and nothing else,
11
+ # deliberately: reading the RBS ancestor chain instead would make a row's reach a function of whether
12
+ # anyone happened to run `rbs prototype`. The cost is that a chain which leaves project source never
13
+ # comes back. `class UserMailer < Devise::Mailer` is the measured case — `Devise::Mailer <
14
+ # ActionMailer::Base` is a line in the devise gem, so `rigor-actionmailer`'s rows stop one step short
15
+ # of every mailer a Devise application writes, and the five `Auth::*Controller < Devise::*Controller`
16
+ # subclasses beside it are entry points.
17
+ #
18
+ # A plugin that models a gem knows that gem's own inheritance. Declaring it here keeps the rule that
19
+ # Rigor reads *declarations* rather than RBS: the declaration simply comes from the plugin instead of
20
+ # from project source.
21
+ #
22
+ # ## What a claim may say
23
+ #
24
+ # `parent:` need only be a **true ancestor**, not the immediate superclass. The sole use of the
25
+ # ancestry is to make a row reachable, and no plugin row is ever keyed on a project class, so
26
+ # skipping intermediate links loses nothing — while insisting on the immediate parent would force a
27
+ # claim that is sometimes false: `Devise::SessionsController`'s real parent is `DeviseController`,
28
+ # whose own parent is `Devise.parent_controller` and therefore configurable per project. A claim that
29
+ # skips links MUST say so in its `why:`.
30
+ #
31
+ # ## Who may make one
32
+ #
33
+ # Bundled plugins only ({Rigor::Plugin::FirstParty}), enforced in `PluginFacts`. An ancestry claim
34
+ # carries no labels of its own, so it looks harmless — but it makes *other* plugins' rows reachable,
35
+ # and a third-party plugin asserting `Foo < ActiveRecord::Base` would pull rigor-activerecord's
36
+ # first-party discharging rows onto `Foo`. The `effect_root:` demotion and the `discharge:` grant
37
+ # both answer their own version of that question the same way.
38
+ class EffectAncestry
39
+ attr_reader :child, :parent, :why
40
+
41
+ def initialize(child:, parent:, why:)
42
+ @child = validate_class_name!(child, "child")
43
+ @parent = validate_class_name!(parent, "parent")
44
+ @why = validate_why!(why)
45
+ freeze
46
+ end
47
+
48
+ def to_h
49
+ { "child" => @child, "parent" => @parent }
50
+ end
51
+
52
+ def ==(other)
53
+ other.is_a?(EffectAncestry) && to_h == other.to_h
54
+ end
55
+ alias eql? ==
56
+
57
+ def hash
58
+ to_h.hash
59
+ end
60
+
61
+ private
62
+
63
+ def validate_class_name!(value, role)
64
+ name = value.to_s
65
+ unless EffectAttribution::CLASS_NAME.match?(name)
66
+ raise ArgumentError, "effect ancestry #{role} must be a class name, got #{value.inspect}"
67
+ end
68
+
69
+ name.dup.freeze
70
+ end
71
+
72
+ def validate_why!(why)
73
+ value = why.to_s
74
+ raise ArgumentError, "effect ancestry #{@child} < #{@parent} needs a `why:` justification" if value.empty?
75
+
76
+ value.dup.freeze
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # One row of a plugin's `effect_attributions:` — "a call to this receiver's method contributes these
6
+ # effect labels" (ADR-103 WD6 / WD10; design note § 6.6; the contract is
7
+ # `docs/internal-spec/plugin.md` § Effect contributions).
8
+ #
9
+ # A framework method has no body Rigor reads, so someone must colour it. Three channels can, in
10
+ # descending order of preference:
11
+ #
12
+ # 1. a `%a{rigor:v1:effect …}` annotation in the plugin's own `signature_paths:` RBS — tier 1, read
13
+ # through {Rigor::Effects::EnvelopeIndex}'s accepted stratum, and the right channel whenever the
14
+ # plugin already ships a signature for the method;
15
+ # 2. this field, for the methods RBS cannot name per app — association readers, `find_by_*`, scopes —
16
+ # and for classes the plugin ships no RBS for at all;
17
+ # 3. the project's own `effects.attribution:` table, which is the user's answer for everything left.
18
+ #
19
+ # ## The receiver
20
+ #
21
+ # `receiver` is spelled one of two ways, and the spelling picks the matching rule:
22
+ #
23
+ # - a **class name** (`"ActiveRecord::Base"`, `"I18n"`) matches the class the call's receiver
24
+ # projects to, **through the project's inheritance chain**: an `ActiveRecord::Base` row applies to
25
+ # `User.find` because the project declares `User < ApplicationRecord < ActiveRecord::Base`. This is
26
+ # the difference from a catalogue row, which matches its exact owner and nothing else — Ruby's core
27
+ # classes are leaves in practice and a framework's base class never is.
28
+ # - a **receiver path** (`"Rails.cache"`, `"Time.zone"`, `"Rails.application.credentials"`) matches
29
+ # the receiver *expression* as the syntax spells it. `Rails.cache.read` has no receiver class the
30
+ # typer can name — `Rails.cache` is a call, and its return type is adapter-dependent by design —
31
+ # so the only honest handle on it is the path that was written.
32
+ # - a **self path** (`"self.session"`, `"self.flash.now"`, `"self.cookies.encrypted"`) is the same
33
+ # thing rooted at implicit self, and is what a Rails controller's accessors actually look like:
34
+ # `session[:user_id] = id` is `[]=` on the result of a receiver-less `session`. A self-path row MUST
35
+ # name a `within:` class, because a receiver-less `session` in some other project class is a
36
+ # different `session` — the row applies only inside a class whose project ancestry reaches `within`.
37
+ #
38
+ # `on_result: true` shifts a class-name row one link outwards: it matches a call on **what a call to
39
+ # that class returned**. `UserMailer.welcome(u).deliver_now` and `WelcomeJob.set(wait: 1.hour)
40
+ # .perform_later` are the two idioms that need it — the object in the middle is a lazy
41
+ # `MessageDelivery` / `ConfiguredJob` whose type nothing in the project declares, while the class that
42
+ # produced it is written right there in the source. Without it the send and the enqueue, the two calls
43
+ # a reviewer most wants coloured, would go unattributed in the spelling Rails actually uses.
44
+ #
45
+ # ## Discharge
46
+ #
47
+ # `discharge: true` says the label is derived from the framework's own semantics rather than guessed,
48
+ # so the site is exhaustive rather than tainted. ADR-103 WD6 grants that only to a **first-party
49
+ # bundled** plugin ({FirstParty}), gated by `make check-plugins`; a third-party plugin's `true` is
50
+ # ignored with a load-time warning and the row behaves like the project's `effects.attribution:`
51
+ # table — declared, and carrying a `plugin-attribution` taint.
52
+ #
53
+ # Either way the labels land in the **declared** lane, never the proven one. A discharging row is a
54
+ # trusted claim, exactly like an accepted signature's `%a{…}`: "this is what it does", not "the
55
+ # analyzer read the body and saw this".
56
+ class EffectAttribution
57
+ # A receiver spelled as a `Constant::Path` — the class-name form. Anything else with a `.` in it is
58
+ # read as a receiver path.
59
+ CLASS_NAME = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/
60
+
61
+ # A receiver path: a constant head followed by one or more sends (`Rails.cache`,
62
+ # `Rails.application.credentials`).
63
+ RECEIVER_PATH = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*(\.[a-z_][A-Za-z0-9_]*)+\z/
64
+
65
+ # A self path: implicit self followed by one or more receiver-less sends (`self.flash.now`).
66
+ SELF_PATH = /\Aself(\.[a-z_][A-Za-z0-9_]*)+\z/
67
+
68
+ # What a self path is rooted at, once the `self.` head is stripped.
69
+ SELF_HEAD = "self"
70
+
71
+ attr_reader :receiver, :method, :singleton, :labels, :narrow, :discharge, :within, :on_result,
72
+ :taint, :why
73
+
74
+ # The taint causes a plugin row may name. A closed subset of
75
+ # {Rigor::Effects::TaintCause::ALL}: a plugin may say "and there is more here I cannot see", but
76
+ # only for the two reasons a framework model can honestly have — a template it does not read, and a
77
+ # callable whose body is supplied by the application.
78
+ TAINT_CAUSES = %w[template-not-analysed opaque-callable].freeze
79
+
80
+ # @param receiver [String] a class name or a receiver path (see above)
81
+ # @param method [Symbol, String] the selector this row colours
82
+ # @param singleton [Boolean] whether the row is `Receiver.method` rather than `Receiver#method`.
83
+ # Meaningless — and ignored — for a receiver path, whose head already fixes the receiver object.
84
+ # @param labels [Array<String>] the effect labels the call contributes
85
+ # @param narrow [String, nil] a {Rigor::Effects::Narrowing} handler name, when the call's own
86
+ # argument literals settle a question the row cannot (`connection.execute("SELECT …")`)
87
+ # @param discharge [Boolean] see above; honoured only for a first-party bundled plugin
88
+ # @param why [String] the audit justification, required exactly as `data/effects/core.yml` requires
89
+ # one of every row: a label with no stated reason is a claim nobody can review.
90
+ def initialize(receiver:, method:, labels:, why:, singleton: false, narrow: nil, discharge: false, # rubocop:disable Metrics/ParameterLists
91
+ within: nil, on_result: false, taint: nil)
92
+ @receiver = validate_receiver!(receiver)
93
+ @method = method.to_sym
94
+ @singleton = singleton ? true : false
95
+ @labels = normalize_labels(labels)
96
+ @narrow = narrow.nil? ? nil : narrow.to_s.dup.freeze
97
+ @discharge = discharge ? true : false
98
+ @within = validate_within!(within)
99
+ @on_result = on_result ? true : false
100
+ validate_on_result!
101
+ @taint = validate_taint!(taint)
102
+ @why = validate_why!(why)
103
+ freeze
104
+ end
105
+
106
+ # Whether {#receiver} is a receiver path (`Rails.cache`) rather than a class name or a self path.
107
+ def receiver_path?
108
+ @receiver.include?(".") && !self_path?
109
+ end
110
+
111
+ # Whether {#receiver} is a self path (`self.flash.now`).
112
+ def self_path?
113
+ @receiver.start_with?("#{SELF_HEAD}.")
114
+ end
115
+
116
+ # The key an origin and a report spell this row as.
117
+ def key
118
+ return "#{@receiver}.#{@method}" if receiver_path? || self_path?
119
+ return "#{@receiver}()##{@method}" if @on_result
120
+
121
+ "#{@receiver}#{@singleton ? '.' : '#'}#{@method}"
122
+ end
123
+
124
+ def to_h
125
+ {
126
+ "receiver" => @receiver, "method" => @method.to_s, "singleton" => @singleton,
127
+ "labels" => @labels, "narrow" => @narrow, "discharge" => @discharge, "within" => @within,
128
+ "on_result" => @on_result, "taint" => @taint
129
+ }
130
+ end
131
+
132
+ def ==(other)
133
+ other.is_a?(EffectAttribution) && to_h == other.to_h
134
+ end
135
+ alias eql? ==
136
+
137
+ def hash
138
+ to_h.hash
139
+ end
140
+
141
+ private
142
+
143
+ def validate_receiver!(receiver)
144
+ value = receiver.to_s
145
+ unless CLASS_NAME.match?(value) || RECEIVER_PATH.match?(value) || SELF_PATH.match?(value)
146
+ raise ArgumentError,
147
+ "effect attribution receiver must be a class name, a receiver path or a self path, " \
148
+ "got #{receiver.inspect}"
149
+ end
150
+
151
+ value.dup.freeze
152
+ end
153
+
154
+ # A self path with no `within:` would colour a receiver-less `session` in any project class at all.
155
+ def validate_within!(within)
156
+ if within.nil?
157
+ raise ArgumentError, "effect attribution #{key} is a self path and must name a `within:` class" if self_path?
158
+
159
+ return nil
160
+ end
161
+
162
+ value = within.to_s
163
+ raise ArgumentError, "effect attribution `within:` must be a class name, got #{within.inspect}" unless
164
+ CLASS_NAME.match?(value)
165
+
166
+ value.dup.freeze
167
+ end
168
+
169
+ def normalize_labels(labels)
170
+ list = Array(labels).map { |label| label.to_s.dup.freeze }
171
+ raise ArgumentError, "effect attribution for #{@receiver} must declare at least one label" if list.empty?
172
+
173
+ list.uniq.sort.freeze
174
+ end
175
+
176
+ # `on_result:` shifts the match one link outwards, which only a class-name row can do: a receiver
177
+ # path already names the object, and a self path already names the frame.
178
+ def validate_on_result!
179
+ return unless @on_result
180
+ return unless receiver_path? || self_path?
181
+
182
+ raise ArgumentError, "effect attribution #{key} may not combine `on_result:` with a path receiver"
183
+ end
184
+
185
+ # A row may state a bound AND say the bound is not the whole story. `render` is the case that needs
186
+ # it: what the controller does IS `mutate.self` + `rails.response.write`, and what the TEMPLATE does
187
+ # is unknown until views become effect units (ADR-103 WD11). Reporting only the first would be a
188
+ # summary that reads exhaustive and is not.
189
+ def validate_taint!(taint)
190
+ return nil if taint.nil?
191
+
192
+ value = taint.to_s
193
+ return value.dup.freeze if TAINT_CAUSES.include?(value)
194
+
195
+ raise ArgumentError,
196
+ "effect attribution #{key} may only taint with one of #{TAINT_CAUSES.inspect}, " \
197
+ "got #{taint.inspect}"
198
+ end
199
+
200
+ def validate_why!(why)
201
+ value = why.to_s
202
+ raise ArgumentError, "effect attribution #{key} needs a `why:` justification" if value.empty?
203
+
204
+ value.dup.freeze
205
+ end
206
+ end
207
+ end
208
+ end