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,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # One entry of a plugin's `effect_edges:` — a call graph edge the *syntax* does not contain but the
6
+ # framework does (ADR-103 WD10; design note § 11.2 "Framework edges").
7
+ #
8
+ # `save` runs the class body's `before_save :normalize` and its validators; `Job.perform_now` runs
9
+ # `Job#perform`; `UserMailer.welcome(u)` runs `UserMailer#welcome`. None of those is a call the
10
+ # analyzer can see, and all three are ordinary synchronous in-process control flow — so a plugin that
11
+ # models the framework contributes **edges**, not only labels.
12
+ #
13
+ # What a plugin must NOT contribute is the deferred half: `perform_later` never edges to `perform`,
14
+ # because the body runs in another process on another stack and the caller's code does not contain it
15
+ # (ADR-103 WD4, "attribution follows the code, not the clock"). The enum below has no spelling for it,
16
+ # which is the enforcement.
17
+ #
18
+ # ## Why a fixed enum rather than a callback
19
+ #
20
+ # A plugin supplies **parameters**; the engine owns the strategy. A block would have to run inside the
21
+ # per-file effect scan — the one place ADR-103 WD13 forbids anything that resolves, walks or types —
22
+ # and would not survive the fork-pool / Ractor boundary the collection window crosses. The enum keeps
23
+ # the plugin's contribution declarative, Marshal-clean and reviewable, and keeps every walk on the
24
+ # engine's side of the contract.
25
+ #
26
+ # ## The strategies
27
+ #
28
+ # - `:activerecord_callbacks` — `receiver:` is the ActiveRecord base class. On every project class
29
+ # whose ancestry reaches it, the engine reads the class body's callback and validation macros
30
+ # (`before_save :sym`, `validate :sym`, `after_commit :sym`, …) and synthesises the persistence
31
+ # selectors (`save`, `create!`, `destroy`, `valid?`, …) as effect units edged to those methods.
32
+ # `validates … uniqueness: true` additionally contributes an `io.db.read` origin, because the
33
+ # uniqueness check IS a query.
34
+ # - `:perform_now` — `receiver:` is the job base class. `Job.perform_now(…)` on a project subclass
35
+ # reaches `Job#perform`.
36
+ # - `:mailer_body` — `receiver:` is the mailer base class. `UserMailer.welcome(u)` on a project
37
+ # subclass reaches `UserMailer#welcome`, the class-method-to-instance mapping ActionMailer performs
38
+ # at run time.
39
+ #
40
+ # `method:` and `singleton:` are carried for a future strategy that keys on one selector; the three
41
+ # above read only `receiver:`.
42
+ class EffectEdge
43
+ # Every strategy the engine implements. A `target:` outside this set is a manifest error, so a
44
+ # plugin cannot silently declare an edge nothing honours.
45
+ TARGETS = %i[activerecord_callbacks perform_now mailer_body].freeze
46
+
47
+ attr_reader :receiver, :method, :singleton, :target, :why
48
+
49
+ def initialize(receiver:, target:, why:, method: nil, singleton: false)
50
+ @receiver = validate_receiver!(receiver)
51
+ @target = validate_target!(target)
52
+ @method = method&.to_sym
53
+ @singleton = singleton ? true : false
54
+ @why = validate_why!(why)
55
+ freeze
56
+ end
57
+
58
+ def to_h
59
+ {
60
+ "receiver" => @receiver, "method" => @method&.to_s, "singleton" => @singleton,
61
+ "target" => @target.to_s
62
+ }
63
+ end
64
+
65
+ def ==(other)
66
+ other.is_a?(EffectEdge) && to_h == other.to_h
67
+ end
68
+ alias eql? ==
69
+
70
+ def hash
71
+ to_h.hash
72
+ end
73
+
74
+ private
75
+
76
+ def validate_receiver!(receiver)
77
+ value = receiver.to_s
78
+ unless EffectAttribution::CLASS_NAME.match?(value)
79
+ raise ArgumentError, "effect edge receiver must be a class name, got #{receiver.inspect}"
80
+ end
81
+
82
+ value.dup.freeze
83
+ end
84
+
85
+ def validate_target!(target)
86
+ value = target.to_sym
87
+ return value if TARGETS.include?(value)
88
+
89
+ raise ArgumentError,
90
+ "effect edge target must be one of #{TARGETS.inspect}, got #{target.inspect}"
91
+ end
92
+
93
+ def validate_why!(why)
94
+ value = why.to_s
95
+ raise ArgumentError, "effect edge #{@receiver} -> #{@target} needs a `why:` justification" if value.empty?
96
+
97
+ value.dup.freeze
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # One entry of a plugin's `effect_entry_points:` — a **named** set of globs that
6
+ # `effects.snapshot.reach:` may adopt by name (ADR-103 WD14).
7
+ #
8
+ # `reach:` is "whose transitive footprint does the snapshot record", and the honest answer for a Rails
9
+ # app — controller actions, `perform`, mailer methods, channel methods — is a fact about the framework
10
+ # rather than about the project. So the framework's plugin names it and the project adopts it:
11
+ #
12
+ # effects:
13
+ # snapshot:
14
+ # reach: [rails]
15
+ #
16
+ # A preset is a **glob** set, matched against the project-relative file a method is defined in, with
17
+ # the `rigor unused --entry-point` semantics {Rigor::Effects::EntryPoints} already implements. Globs
18
+ # rather than a class-ancestry filter, deliberately: `reach:` is resolved from the snapshot's own
19
+ # method table, which carries a defining path per key and no ancestry — and a Rails app's layout IS
20
+ # the ancestry, which is why the convention exists at all.
21
+ class EffectEntryPoints
22
+ attr_reader :name, :globs, :why
23
+
24
+ # @param name [String, Symbol] the token `reach:` adopts. Must satisfy
25
+ # {Rigor::Effects::EntryPoints::NAME_PATTERN}, checked at registration.
26
+ # @param globs [Array<String>] project-relative path globs
27
+ # @param why [String] what the preset stands for, for the plugin's README and `rigor effects`' help
28
+ def initialize(name:, globs:, why: "")
29
+ @name = name.to_s.dup.freeze
30
+ @globs = Array(globs).map { |glob| glob.to_s.dup.freeze }.uniq.sort.freeze
31
+ raise ArgumentError, "effect entry-point preset #{@name.inspect} declares no globs" if @globs.empty?
32
+
33
+ @why = why.to_s.dup.freeze
34
+ freeze
35
+ end
36
+
37
+ def to_h
38
+ { "name" => @name, "globs" => @globs }
39
+ end
40
+
41
+ def ==(other)
42
+ other.is_a?(EffectEntryPoints) && to_h == other.to_h
43
+ end
44
+ alias eql? ==
45
+
46
+ def hash
47
+ to_h.hash
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # Which plugin ids the engine itself ships (ADR-103 WD6).
6
+ #
7
+ # WD6's trust ladder grants two things to a **first-party bundled** plugin and to nothing else: it may
8
+ # open the effect-label root of the framework it models rather than one named after itself
9
+ # (`rails.*`, not `activerecord.*`), and its `effect_attributions:` may carry `discharge: true`. Both
10
+ # rest on the same fact — a bundled plugin's framework knowledge is versioned with the engine, reviewed
11
+ # in this repository, and gated by `make check-plugins`, which is exactly the standing a gem's shipped
12
+ # RBS has.
13
+ #
14
+ # The answer is **derived, never listed**: a plugin is first-party when the engine bundles the gem that
15
+ # would register it, which is the same question {Loader.bundled_plugin_path} already answers when it
16
+ # decides whether to require a plugin by its engine-anchored path or by gem name. Every bundled plugin
17
+ # id equals its gem name minus the `rigor-` prefix (pinned by spec), so the id is enough to ask. A list
18
+ # would be a second source of truth to keep in sync with `plugins/`, and the first drift would silently
19
+ # demote a plugin's rows to the tainted lane.
20
+ #
21
+ # This is a trust *ladder*, not a sandbox. ADR-2 settles that plugins are trusted gems the user
22
+ # selected and chooses documentation over forced isolation; a plugin determined to spoof a bundled id
23
+ # is out of scope there and stays out of scope here. What the predicate buys is that an ordinary
24
+ # third-party plugin cannot accidentally claim authority it was never granted.
25
+ module FirstParty
26
+ # The prefix a bundled plugin's gem name carries over its manifest id.
27
+ GEM_PREFIX = "rigor-"
28
+
29
+ @memo = {}
30
+
31
+ class << self
32
+ # Whether `id` names a plugin the engine bundles.
33
+ #
34
+ # Memoised: the answer is a `File.file?` on a path fixed for the process, and the effect surfaces
35
+ # ask it once per plugin per run — but a plugin's own specs ask it far more often than that.
36
+ def bundled?(id)
37
+ key = id.to_s
38
+ return false if key.empty?
39
+
40
+ return @memo[key] if @memo.key?(key)
41
+
42
+ # Required here rather than at the top of the file: {Loader} pulls in the plugin {Registry},
43
+ # which pulls in {Manifest}, which asks this module the question — a `require` cycle that would
44
+ # leave `Loader` undefined half-way through load. The require is idempotent and this method is
45
+ # memoised, so it costs one `$LOADED_FEATURES` probe per process.
46
+ require_relative "loader"
47
+ @memo[key] = !Loader.bundled_plugin_path("#{GEM_PREFIX}#{key}").nil?
48
+ end
49
+
50
+ # Drops the memo. For specs that stub the engine root only.
51
+ def reset!
52
+ @memo = {}
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -43,8 +43,11 @@ module Rigor
43
43
  @target_bundle_root
44
44
  end
45
45
 
46
+ # Frozen so the value stays `Ractor.shareable?`: a Ractor-pool worker READS this (its Environment
47
+ # build resolves the same root and skips the write — #414), and reading an unshareable value out of
48
+ # a module ivar from a non-main Ractor is the same `Ractor::IsolationError` as writing one.
46
49
  def target_bundle_root=(root)
47
- @target_bundle_root = root.nil? ? nil : File.expand_path(root.to_s)
50
+ @target_bundle_root = root.nil? ? nil : File.expand_path(root.to_s).freeze
48
51
  end
49
52
 
50
53
  # Requires `feature`, falling back to the analyzed project's bundler install tree. The fallback
@@ -3,6 +3,11 @@
3
3
  require_relative "../inference/hkt_registry"
4
4
  require_relative "protocol_contract"
5
5
  require_relative "additional_initializer"
6
+ require_relative "effect_attribution"
7
+ require_relative "effect_edge"
8
+ require_relative "effect_ancestry"
9
+ require_relative "effect_entry_points"
10
+ require_relative "first_party"
6
11
 
7
12
  module Rigor
8
13
  module Plugin
@@ -33,11 +38,17 @@ module Rigor
33
38
  end
34
39
  end
35
40
 
41
+ # ADR-103 WD2 — what a plugin's `effect_root:` may be spelled as: one label segment, the shape
42
+ # {Rigor::Effects::Label} accepts as a root.
43
+ VALID_EFFECT_ROOT = /\A[a-z][a-z0-9_]*\z/
44
+
36
45
  attr_reader :id, :version, :description, :config_schema, :config_defaults, :produces, :consumes,
37
46
  :owns_receivers, :open_receivers, :type_node_resolvers, :block_as_methods,
38
47
  :heredoc_templates, :nested_class_templates, :trait_registries,
39
48
  :hkt_registrations, :hkt_definitions, :signature_paths, :protocol_contracts,
40
- :source_rbs_synthesizer, :additional_initializers
49
+ :source_rbs_synthesizer, :additional_initializers,
50
+ :effect_root, :effect_labels, :effect_attributions, :effect_edges, :effect_entry_points,
51
+ :effect_ancestry
41
52
 
42
53
  def initialize( # rubocop:disable Metrics/ParameterLists
43
54
  id:, version:,
@@ -46,7 +57,9 @@ module Rigor
46
57
  block_as_methods: [], heredoc_templates: [], nested_class_templates: [],
47
58
  trait_registries: [],
48
59
  hkt_registrations: [], hkt_definitions: [], signature_paths: [], protocol_contracts: [],
49
- source_rbs_synthesizer: nil, additional_initializers: []
60
+ source_rbs_synthesizer: nil, additional_initializers: [],
61
+ effect_root: nil, effect_labels: [], effect_attributions: [], effect_edges: [],
62
+ effect_entry_points: [], effect_ancestry: []
50
63
  )
51
64
  validate_id!(id)
52
65
  validate_version!(version)
@@ -65,6 +78,8 @@ module Rigor
65
78
  validate_protocol_contracts!(protocol_contracts)
66
79
  validate_source_rbs_synthesizer!(source_rbs_synthesizer)
67
80
  validate_additional_initializers!(additional_initializers)
81
+ validate_effect_fields!(effect_root, effect_labels, effect_attributions, effect_edges,
82
+ effect_entry_points, effect_ancestry)
68
83
 
69
84
  assign_fields(id, version, description, config_schema, produces, consumes, owns_receivers,
70
85
  open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
@@ -72,6 +87,8 @@ module Rigor
72
87
  source_rbs_synthesizer)
73
88
  assign_nested_class_templates(nested_class_templates)
74
89
  assign_additional_initializers(additional_initializers)
90
+ assign_effect_fields(effect_root, effect_labels, effect_attributions, effect_edges,
91
+ effect_entry_points, effect_ancestry)
75
92
  freeze
76
93
  end
77
94
 
@@ -115,6 +132,31 @@ module Rigor
115
132
  @additional_initializers = additional_initializers.dup.freeze
116
133
  end
117
134
  private :assign_additional_initializers
135
+
136
+ # ADR-103 WD2 / WD6 / WD10 / WD14 — the effect contract's five fields, assigned together and outside
137
+ # `assign_fields` (which already carries the maximum positional arity).
138
+ # The six `effect_*` fields validate together, so {#initialize} carries one call rather than six.
139
+ def validate_effect_fields!(effect_root, effect_labels, effect_attributions, effect_edges,
140
+ effect_entry_points, effect_ancestry)
141
+ validate_effect_root!(effect_root)
142
+ validate_effect_labels!(effect_labels)
143
+ validate_effect_attributions!(effect_attributions)
144
+ validate_effect_edges!(effect_edges)
145
+ validate_effect_entry_points!(effect_entry_points)
146
+ validate_effect_ancestry!(effect_ancestry)
147
+ end
148
+ private :validate_effect_fields!
149
+
150
+ def assign_effect_fields(effect_root, effect_labels, effect_attributions, effect_edges,
151
+ effect_entry_points, effect_ancestry)
152
+ @effect_root = effect_root.nil? ? nil : effect_root.to_s.dup.freeze
153
+ @effect_labels = effect_labels.map { |label| label.to_s.dup.freeze }.uniq.sort.freeze
154
+ @effect_attributions = effect_attributions.dup.freeze
155
+ @effect_edges = effect_edges.dup.freeze
156
+ @effect_entry_points = effect_entry_points.dup.freeze
157
+ @effect_ancestry = effect_ancestry.dup.freeze
158
+ end
159
+ private :assign_effect_fields
118
160
  # rubocop:enable Metrics/ParameterLists, Metrics/AbcSize
119
161
 
120
162
  public
@@ -161,10 +203,39 @@ module Rigor
161
203
  "signature_paths" => signature_paths,
162
204
  "protocol_contracts" => protocol_contracts.map(&:to_h),
163
205
  "source_rbs_synthesizer" => source_rbs_synthesizer&.class&.name,
164
- "additional_initializers" => additional_initializers.map(&:to_h)
206
+ "additional_initializers" => additional_initializers.map(&:to_h),
207
+ "effect_root" => effect_root,
208
+ "effect_labels" => effect_labels,
209
+ "effect_attributions" => effect_attributions.map(&:to_h),
210
+ "effect_edges" => effect_edges.map(&:to_h),
211
+ "effect_entry_points" => effect_entry_points.map(&:to_h),
212
+ "effect_ancestry" => effect_ancestry.map(&:to_h)
165
213
  }
166
214
  end
167
215
 
216
+ # ADR-103 WD2 — the identity opening a root when this plugin's labels join the run's vocabulary: the
217
+ # framework root it models for a first-party bundled plugin that declares one, and the plugin id
218
+ # otherwise. A third-party plugin's `effect_root:` is ignored here (and warned about by the registry),
219
+ # so it opens only the root named after itself.
220
+ def effect_owner
221
+ return id if effect_root.nil?
222
+ return id unless FirstParty.bundled?(id)
223
+
224
+ effect_root
225
+ end
226
+
227
+ # Whether this plugin's `effect_attributions:` may carry `discharge: true` (ADR-103 WD6).
228
+ def effect_discharge_allowed?
229
+ FirstParty.bundled?(id)
230
+ end
231
+
232
+ # Whether the plugin contributes anything to the effect surfaces at all. The compiled per-run tables
233
+ # skip a plugin that answers false, so a project whose plugins predate this contract pays nothing.
234
+ def effects?
235
+ !effect_labels.empty? || !effect_attributions.empty? || !effect_edges.empty? ||
236
+ !effect_entry_points.empty?
237
+ end
238
+
168
239
  def ==(other)
169
240
  other.is_a?(Manifest) && to_h == other.to_h
170
241
  end
@@ -414,6 +485,59 @@ module Rigor
414
485
  "got #{synthesizer.inspect}"
415
486
  end
416
487
 
488
+ # ADR-103 WD2 — `effect_root:` names the effect-label root this plugin opens when it is one the
489
+ # engine bundles: rigor-activejob models ActiveJob and therefore opens `rails.*`, not `activejob.*`.
490
+ # Declaring one is only ever a *request*; {#effect_owner} grants it to a first-party plugin and
491
+ # ignores it otherwise, so the field is safe to accept from anyone.
492
+ def validate_effect_root!(root)
493
+ return if root.nil?
494
+ return if root.is_a?(String) && root.match?(VALID_EFFECT_ROOT)
495
+
496
+ raise ArgumentError,
497
+ "plugin manifest effect_root must match #{VALID_EFFECT_ROOT.inspect}, got #{root.inspect}"
498
+ end
499
+
500
+ # ADR-103 WD2 — `effect_labels:` lists the vocabulary this plugin registers. The engine folds them
501
+ # into the run's {Rigor::Effects::Registry} through `#with(labels:, owner: manifest.effect_owner)`,
502
+ # which is where root ownership is enforced; a label outside the plugin's root is a load-time plugin
503
+ # error, reported like any other. Only the spelling is checked here.
504
+ def validate_effect_labels!(labels)
505
+ validate_array_of!("effect_labels", labels, "non-empty String") { |l| l.is_a?(String) && !l.empty? }
506
+ end
507
+
508
+ # ADR-103 WD6 / WD10 — `effect_attributions:` colours calls into the framework this plugin models.
509
+ # Each entry MUST be a {Rigor::Plugin::EffectAttribution}.
510
+ def validate_effect_attributions!(entries)
511
+ validate_array_of!("effect_attributions", entries, "Rigor::Plugin::EffectAttribution instances") do |e|
512
+ e.is_a?(EffectAttribution)
513
+ end
514
+ end
515
+
516
+ # ADR-103 WD10 — `effect_edges:` declares the framework edges the syntax lacks. Each entry MUST be a
517
+ # {Rigor::Plugin::EffectEdge}, whose `target:` is one of a fixed engine-side strategy enum.
518
+ def validate_effect_edges!(entries)
519
+ validate_array_of!("effect_edges", entries, "Rigor::Plugin::EffectEdge instances") do |e|
520
+ e.is_a?(EffectEdge)
521
+ end
522
+ end
523
+
524
+ # ADR-103 WD17 (#465) — `effect_ancestry:` states an ancestry edge the plugin's own gem introduces
525
+ # and the project's source never writes. Each entry MUST be a {Rigor::Plugin::EffectAncestry}; only
526
+ # a bundled plugin's survives, which `Effects::PluginFacts` enforces.
527
+ def validate_effect_ancestry!(entries)
528
+ validate_array_of!("effect_ancestry", entries, "Rigor::Plugin::EffectAncestry instances") do |e|
529
+ e.is_a?(EffectAncestry)
530
+ end
531
+ end
532
+
533
+ # ADR-103 WD14 — `effect_entry_points:` names the `effects.snapshot.reach:` presets this plugin
534
+ # supplies. Each entry MUST be a {Rigor::Plugin::EffectEntryPoints}.
535
+ def validate_effect_entry_points!(entries)
536
+ validate_array_of!("effect_entry_points", entries, "Rigor::Plugin::EffectEntryPoints instances") do |e|
537
+ e.is_a?(EffectEntryPoints)
538
+ end
539
+ end
540
+
417
541
  def coerce_consumes(consumes)
418
542
  unless consumes.is_a?(Array)
419
543
  raise ArgumentError, "plugin manifest consumes must be an Array, got #{consumes.inspect}"
@@ -244,6 +244,36 @@ module Rigor
244
244
  # ADR-52 WD4 — the per-run node-rule walk (see {NodeRuleWalk}).
245
245
  attr_reader :node_rule_walk
246
246
 
247
+ # ADR-103 WD2 / WD6 / WD10 / WD14 (#387) — every loaded plugin's effect contribution, in
248
+ # registration order, as {Contribution} rows.
249
+ #
250
+ # **Lazy, unlike the other aggregates.** A plugin MAY compute its `effect_attributions:` from project
251
+ # facts — rigor-activejob reads `config.active_job.queue_adapter` to decide whether an enqueue is a
252
+ # database write or a Redis one — and that is an I/O-boundary read a `rigor check` with no `effects:`
253
+ # block must never pay. Nothing asks for this until {Rigor::Effects::PluginFacts.build} does, and
254
+ # nothing calls that unless collection is on. Memoised into the frozen registry the same way
255
+ # `contracts_for_path` memoises, and for the same reason: the answer is fixed for the run.
256
+ def effect_contributions
257
+ @effect_memo[:contributions] ||= compile_effect_contributions
258
+ end
259
+
260
+ # One plugin's effect contribution, flattened off whichever surface answered it (the manifest, or the
261
+ # plugin's own override). Carries the two authority answers with it — `owner`, the label root this
262
+ # plugin may open, and `discharge_allowed`, whether its attributions may discharge — so nothing
263
+ # downstream has to re-derive who is first-party.
264
+ Contribution = Data.define(:id, :owner, :requested_root, :discharge_allowed, :labels, :attributions,
265
+ :edges, :entry_points, :ancestry) do
266
+ # `ancestry:` defaults so a caller written before #465 — and every spec that constructs one by
267
+ # hand — keeps working; a plugin that declares none contributes none.
268
+ def initialize(ancestry: [], **rest)
269
+ super
270
+ end
271
+
272
+ def empty?
273
+ labels.empty? && attributions.empty? && edges.empty? && entry_points.empty? && ancestry.empty?
274
+ end
275
+ end
276
+
247
277
  # ADR-15 Phase 3 — build a fresh Registry from the supplied blueprint set by replaying
248
278
  # {Blueprint#materialize} per entry against `services`. The returned registry carries NEW plugin
249
279
  # instances (mutable per-Ractor accumulators included) and the same blueprint set, so a worker can hand
@@ -379,6 +409,31 @@ module Rigor
379
409
  @open_receivers_set = @open_receivers.to_set.freeze
380
410
  @protocol_contracts = @plugins.flat_map { |p| safe_protocol_contracts(p) }.freeze
381
411
  @contracts_by_path = {}
412
+ @effect_memo = {}
413
+ end
414
+
415
+ # Fail-soft per plugin: a plugin whose `effect_attributions:` override raises (a missing
416
+ # `config/application.rb`, an unreadable path) contributes nothing rather than taking the run down.
417
+ # An effect contribution is an enrichment; ADR-2 isolates plugin failures at the analyzer boundary
418
+ # and this is that boundary.
419
+ def compile_effect_contributions
420
+ @plugins.filter_map { |plugin| effect_contribution_for(plugin) }.freeze
421
+ end
422
+
423
+ def effect_contribution_for(plugin)
424
+ manifest = safe_manifest(plugin)
425
+ return nil if manifest.nil?
426
+
427
+ contribution = Contribution.new(
428
+ id: manifest.id, owner: manifest.effect_owner, requested_root: manifest.effect_root,
429
+ discharge_allowed: manifest.effect_discharge_allowed?,
430
+ labels: plugin.effect_labels, attributions: plugin.effect_attributions,
431
+ edges: plugin.effect_edges, entry_points: plugin.effect_entry_points,
432
+ ancestry: plugin.effect_ancestry
433
+ )
434
+ contribution.empty? ? nil : contribution
435
+ rescue StandardError
436
+ nil
382
437
  end
383
438
 
384
439
  def path_matches_glob?(glob, path)
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
@@ -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