rigortype 0.1.10 → 0.1.12

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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. metadata +157 -1
@@ -0,0 +1,182 @@
1
+ # ActiveRecord::Relation — generic signature bundled by
2
+ # rigor-activerecord (ADR-25 plugin-contributed RBS).
3
+ #
4
+ # Purpose: give `has_many` / `has_and_belongs_to_many` accessors,
5
+ # `Model.where(...)` / `Model.all` / `Model.order(...)` and
6
+ # user-declared `scope`s a precise *element* type. The plugin
7
+ # contributes `ActiveRecord::Relation[Model]` for those call
8
+ # sites; this file is what makes the chained relation methods
9
+ # (`.where`, `.order`, `.first`, `.each { |m| ... }`, …) resolve.
10
+ #
11
+ # This is NOT a faithful transcription of Rails' Relation API —
12
+ # it is the surface Rigor needs to type-check idiomatic relation
13
+ # usage WITHOUT false positives. Two design rules:
14
+ #
15
+ # - Chain methods return `self`, so `Relation[Model]` survives an
16
+ # arbitrarily long query chain.
17
+ # - The method list is deliberately generous. A method that is
18
+ # genuinely on `ActiveRecord::Relation` but missing here would
19
+ # surface as a false `call.undefined-method` once the plugin
20
+ # has typed the receiver — so the list errs toward inclusion.
21
+ # `include Enumerable[Elem]` carries the whole iteration
22
+ # surface; the class body only adds relation-specific methods
23
+ # and the ones whose argument shape differs from Enumerable's.
24
+ #
25
+ # If the project supplies its own ActiveRecord RBS (e.g. via
26
+ # `rbs collection`), that definition conflicts with this one and
27
+ # the conflict degrades through Rigor's plugin-RBS failure memo
28
+ # (ADR-25 WD4) — the richer upstream definition simply wins.
29
+
30
+ module ActiveRecord
31
+ class Relation[Elem]
32
+ include Enumerable[Elem]
33
+
34
+ # `Enumerable` only *requires* `each` (the includer supplies
35
+ # it); it is declared here so `relation.each { |m| ... }`
36
+ # resolves and the block parameter carries the element type.
37
+ def each: () { (Elem) -> void } -> self
38
+ | () -> Enumerator[Elem, self]
39
+
40
+ # --- query builders (return another relation) ---
41
+ def where: (*untyped) ?{ () -> untyped } -> self
42
+ def rewhere: (*untyped) -> self
43
+ def not: (*untyped) -> self
44
+ def or: (untyped) -> self
45
+ def and: (untyped) -> self
46
+ def order: (*untyped) -> self
47
+ def reorder: (*untyped) -> self
48
+ def in_order_of: (*untyped) -> self
49
+ def limit: (untyped) -> self
50
+ def offset: (untyped) -> self
51
+ def distinct: (?untyped) -> self
52
+ def group: (*untyped) -> self
53
+ def regroup: (*untyped) -> self
54
+ def having: (*untyped) -> self
55
+ def includes: (*untyped) -> self
56
+ def preload: (*untyped) -> self
57
+ def eager_load: (*untyped) -> self
58
+ def joins: (*untyped) -> self
59
+ def left_joins: (*untyped) -> self
60
+ def left_outer_joins: (*untyped) -> self
61
+ def references: (*untyped) -> self
62
+ def select: (*untyped) -> self
63
+ def reselect: (*untyped) -> self
64
+ def unscope: (*untyped) -> self
65
+ def merge: (untyped) -> self
66
+ def except: (*untyped) -> self
67
+ def only: (*untyped) -> self
68
+ def extending: (*untyped) -> self
69
+ def from: (untyped, ?untyped) -> self
70
+ def lock: (?untyped) -> self
71
+ def readonly: (?untyped) -> self
72
+ def strict_loading: (?untyped) -> self
73
+ def create_with: (untyped) -> self
74
+ def none: () -> self
75
+ def all: () -> self
76
+ def unscoped: () -> self
77
+ def default_scoped: () -> self
78
+ def annotate: (*untyped) -> self
79
+ def optimizer_hints: (*untyped) -> self
80
+ def excluding: (*untyped) -> self
81
+ def without: (*untyped) -> self
82
+ def with: (*untyped) -> self
83
+ def scoping: () { () -> untyped } -> untyped
84
+
85
+ # --- finders (return an element) ---
86
+ def find: (*untyped) -> Elem
87
+ def find_by: (*untyped) -> Elem?
88
+ def find_by!: (*untyped) -> Elem
89
+ def find_sole_by: (*untyped) -> Elem
90
+ def sole: () -> Elem
91
+ def first: () -> Elem?
92
+ | (Integer limit) -> Array[Elem]
93
+ def first!: () -> Elem
94
+ def last: () -> Elem?
95
+ | (Integer limit) -> Array[Elem]
96
+ def last!: () -> Elem
97
+ def take: () -> Elem?
98
+ | (Integer limit) -> Array[Elem]
99
+ def take!: () -> Elem
100
+ def second: () -> Elem?
101
+ def third: () -> Elem?
102
+ def fourth: () -> Elem?
103
+ def fifth: () -> Elem?
104
+ def forty_two: () -> Elem?
105
+ def second_to_last: () -> Elem?
106
+ def third_to_last: () -> Elem?
107
+
108
+ # --- batched enumeration ---
109
+ def find_each: (**untyped) ?{ (Elem) -> void } -> untyped
110
+ def find_in_batches: (**untyped) ?{ (Array[Elem]) -> void } -> untyped
111
+ def in_batches: (**untyped) ?{ (ActiveRecord::Relation[Elem]) -> void } -> untyped
112
+
113
+ # --- scalars / aggregates ---
114
+ def count: (*untyped) ?{ (Elem) -> untyped } -> untyped
115
+ def sum: (*untyped) ?{ (Elem) -> untyped } -> untyped
116
+ def average: (untyped) -> untyped
117
+ def minimum: (untyped) -> untyped
118
+ def maximum: (untyped) -> untyped
119
+ def calculate: (*untyped) -> untyped
120
+ def pluck: (*untyped) -> Array[untyped]
121
+ def pick: (*untyped) -> untyped
122
+ def ids: () -> Array[untyped]
123
+ def size: () -> Integer
124
+ def length: () -> Integer
125
+ def empty?: () -> bool
126
+ def any?: (*untyped) ?{ (Elem) -> boolish } -> bool
127
+ def none?: (*untyped) ?{ (Elem) -> boolish } -> bool
128
+ def one?: (*untyped) ?{ (Elem) -> boolish } -> bool
129
+ def many?: () ?{ (Elem) -> boolish } -> bool
130
+ def exists?: (*untyped) -> bool
131
+ def include?: (untyped) -> bool
132
+ def member?: (untyped) -> bool
133
+
134
+ # --- conversion / state ---
135
+ def to_a: () -> Array[Elem]
136
+ def to_ary: () -> Array[Elem]
137
+ def entries: () -> Array[Elem]
138
+ def load: (?untyped) -> self
139
+ def reload: () -> self
140
+ def reset: () -> self
141
+ def loaded?: () -> bool
142
+ def to_sql: () -> String
143
+ def explain: (*untyped) -> String
144
+ def cache_key: (?untyped) -> String
145
+ def cache_version: (?untyped) -> untyped
146
+ def to_json: (*untyped) -> String
147
+ def as_json: (?untyped) -> untyped
148
+ def blank?: () -> bool
149
+ def present?: () -> bool
150
+ def klass: () -> untyped
151
+ def model: () -> untyped
152
+ def table: () -> untyped
153
+ def arel: () -> untyped
154
+
155
+ # --- whole-relation persistence ---
156
+ def update_all: (untyped) -> Integer
157
+ def delete_all: () -> Integer
158
+ def destroy_all: () -> Array[Elem]
159
+ def update: (*untyped) -> untyped
160
+ def update!: (*untyped) -> untyped
161
+ def delete_by: (*untyped) -> Integer
162
+ def destroy_by: (*untyped) -> Array[Elem]
163
+ def touch_all: (*untyped) -> Integer
164
+ def insert_all: (untyped, **untyped) -> untyped
165
+ def insert_all!: (untyped, **untyped) -> untyped
166
+ def upsert_all: (untyped, **untyped) -> untyped
167
+
168
+ # --- instantiating builders (return an element) ---
169
+ def new: (*untyped) ?{ (Elem) -> void } -> Elem
170
+ def build: (*untyped) ?{ (Elem) -> void } -> Elem
171
+ def create: (*untyped) ?{ (Elem) -> void } -> Elem
172
+ def create!: (*untyped) ?{ (Elem) -> void } -> Elem
173
+ def find_or_create_by: (untyped) ?{ (Elem) -> void } -> Elem
174
+ def find_or_create_by!: (untyped) ?{ (Elem) -> void } -> Elem
175
+ def find_or_initialize_by: (untyped) ?{ (Elem) -> void } -> Elem
176
+ def create_or_find_by: (untyped) ?{ (Elem) -> void } -> Elem
177
+ def create_or_find_by!: (untyped) ?{ (Elem) -> void } -> Elem
178
+ def first_or_create: (?untyped) ?{ (Elem) -> void } -> Elem
179
+ def first_or_create!: (?untyped) ?{ (Elem) -> void } -> Elem
180
+ def first_or_initialize: (?untyped) ?{ (Elem) -> void } -> Elem
181
+ end
182
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Activestorage < Rigor::Plugin::Base
8
+ # Per-file walker. Emits one `:info` `attachment-call`
9
+ # diagnostic per recognised attachment access on a
10
+ # known AR class. The diagnostic surfaces what the
11
+ # plugin recognised so users can verify the model →
12
+ # attachment mapping the plugin sees.
13
+ #
14
+ # No `:error` diagnostics in this slice — the
15
+ # `flow_contribution_for` return-type narrowing carries
16
+ # the type-checking value; surfacing unknown attachment
17
+ # names as errors requires a coupled receiver-class
18
+ # narrowing pass that the integration spec doesn't yet
19
+ # rely on. A future slice can add `unknown-attachment`
20
+ # similar to `rigor-activerecord`'s `unknown-column`.
21
+ class Analyzer
22
+ attr_reader :diagnostics
23
+
24
+ def initialize(path:, attachment_index:)
25
+ @path = path
26
+ @attachment_index = attachment_index
27
+ @diagnostics = []
28
+ end
29
+
30
+ def analyze(root)
31
+ walk(root) { |node| visit_call(node) if node.is_a?(Prism::CallNode) }
32
+ self
33
+ end
34
+
35
+ private
36
+
37
+ def walk(node, &)
38
+ return if node.nil?
39
+
40
+ yield node
41
+ node.compact_child_nodes.each { |child| walk(child, &) }
42
+ end
43
+
44
+ def visit_call(node)
45
+ return unless node.receiver.is_a?(Prism::ConstantReadNode)
46
+
47
+ owner = node.receiver.name.to_s
48
+ attachments = @attachment_index.attachments_for(owner) ||
49
+ @attachment_index.attachments_for("::#{owner}")
50
+ return if attachments.nil?
51
+
52
+ # Only flag when the method matches a known
53
+ # attachment name (the `flow_contribution_for`
54
+ # tier provides the narrowing; the diagnostic just
55
+ # confirms the recognition).
56
+ attachment = attachments.find { |a| a[:name] == node.name.to_s }
57
+ return if attachment.nil?
58
+
59
+ push_info(node, "attachment-call",
60
+ "`#{owner}.#{attachment[:name]}` returns " \
61
+ "ActiveStorage::Attached::#{attachment[:kind] == :singular ? 'One' : 'Many'}")
62
+ end
63
+
64
+ def push_info(node, rule, message)
65
+ location = node.location
66
+ @diagnostics << Rigor::Analysis::Diagnostic.new(
67
+ path: @path,
68
+ line: location.start_line,
69
+ column: location.start_column + 1,
70
+ message: message,
71
+ severity: :info,
72
+ rule: rule
73
+ )
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Activestorage < Rigor::Plugin::Base
8
+ # Walks the configured model search paths via the plugin's
9
+ # `IoBoundary`, parses each `.rb` file with Prism, and
10
+ # collects `has_one_attached` / `has_many_attached`
11
+ # declarations.
12
+ #
13
+ # Returns rows the {AttachmentIndex} consumes:
14
+ #
15
+ # { class_name: "User",
16
+ # attachments: [{ name: "avatar", kind: :singular },
17
+ # { name: "photos", kind: :collection }] }
18
+ #
19
+ # Limitations (intentional for v0.1.0 of the plugin):
20
+ #
21
+ # - The walker matches any class declaration that
22
+ # contains a `has_*_attached` call. Whether the class
23
+ # IS an ActiveRecord model is not verified here —
24
+ # `rigor-activerecord` provides that check via its
25
+ # own model index. The analyser is intentionally
26
+ # lenient so the plugin runs standalone in projects
27
+ # that haven't loaded `rigor-activerecord` yet.
28
+ # - Only Symbol-literal attachment names are recognised.
29
+ # `has_one_attached(args)` or computed names decline.
30
+ # - Modules (`class Admin::User`) are recognised; the
31
+ # resulting class name is the lexical path
32
+ # (`Admin::User`).
33
+ class AttachmentDiscoverer
34
+ ATTACHMENT_METHODS = {
35
+ has_one_attached: :singular,
36
+ has_many_attached: :collection
37
+ }.freeze
38
+ private_constant :ATTACHMENT_METHODS
39
+
40
+ def initialize(io_boundary:, search_paths:)
41
+ @io_boundary = io_boundary
42
+ @search_paths = search_paths
43
+ end
44
+
45
+ def discover
46
+ rows = []
47
+ ruby_files_under(@search_paths).each do |path|
48
+ contents = read_safely(path)
49
+ next if contents.nil?
50
+
51
+ tree = Prism.parse(contents).value
52
+ walk(tree, []) do |class_name, attachments|
53
+ rows << { class_name: class_name, attachments: attachments } unless attachments.empty?
54
+ end
55
+ end
56
+ rows
57
+ end
58
+
59
+ private
60
+
61
+ def read_safely(path)
62
+ @io_boundary.read_file(path)
63
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
64
+ nil
65
+ end
66
+
67
+ def ruby_files_under(roots)
68
+ roots.flat_map do |root|
69
+ absolute = File.expand_path(root)
70
+ next [] unless File.directory?(absolute)
71
+
72
+ Dir.glob(File.join(absolute, "**", "*.rb"))
73
+ end
74
+ end
75
+
76
+ def walk(node, lexical_path, &)
77
+ return if node.nil?
78
+
79
+ case node
80
+ when Prism::ClassNode
81
+ visit_class(node, lexical_path, &)
82
+ when Prism::ModuleNode
83
+ visit_module(node, lexical_path, &)
84
+ else
85
+ node.compact_child_nodes.each { |child| walk(child, lexical_path, &) }
86
+ end
87
+ end
88
+
89
+ def visit_class(node, lexical_path, &)
90
+ class_local_name = constant_path_name(node.constant_path)
91
+ return if class_local_name.nil?
92
+
93
+ full_name = (lexical_path + [class_local_name]).join("::")
94
+ attachments = collect_attachments(node.body)
95
+ yield full_name, attachments
96
+
97
+ inner_path = lexical_path + [class_local_name]
98
+ walk(node.body, inner_path, &) if node.body
99
+ end
100
+
101
+ def visit_module(node, lexical_path, &)
102
+ module_local_name = constant_path_name(node.constant_path)
103
+ return if module_local_name.nil?
104
+
105
+ inner_path = lexical_path + [module_local_name]
106
+ walk(node.body, inner_path, &) if node.body
107
+ end
108
+
109
+ def collect_attachments(body)
110
+ return [] if body.nil?
111
+
112
+ rows = []
113
+ body.compact_child_nodes.each do |node|
114
+ next unless node.is_a?(Prism::CallNode)
115
+
116
+ kind = ATTACHMENT_METHODS[node.name]
117
+ next if kind.nil?
118
+ next if node.receiver # skip self.has_one_attached and similar
119
+
120
+ name = symbol_literal_arg(node)
121
+ next if name.nil?
122
+
123
+ rows << { name: name, kind: kind }
124
+ end
125
+ rows
126
+ end
127
+
128
+ def symbol_literal_arg(node)
129
+ args = node.arguments&.arguments
130
+ return nil if args.nil? || args.empty?
131
+
132
+ first = args.first
133
+ return nil unless first.is_a?(Prism::SymbolNode)
134
+
135
+ first.unescaped
136
+ end
137
+
138
+ def constant_path_name(node)
139
+ return nil if node.nil?
140
+
141
+ case node
142
+ when Prism::ConstantReadNode
143
+ node.name.to_s
144
+ when Prism::ConstantPathNode
145
+ parts = []
146
+ current = node
147
+ while current.is_a?(Prism::ConstantPathNode)
148
+ parts.unshift(current.name.to_s)
149
+ current = current.parent
150
+ end
151
+ case current
152
+ when nil
153
+ "::#{parts.join('::')}"
154
+ when Prism::ConstantReadNode
155
+ "#{current.name}::#{parts.join('::')}"
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Activestorage < Rigor::Plugin::Base
6
+ # Maps a discovered class name to the list of attachment
7
+ # rows declared on it. Marshal-clean so the cache producer
8
+ # round-trips it through the standard pair.
9
+ #
10
+ # Each row is `{ name:, kind: }`:
11
+ #
12
+ # - `name` — String, the attachment method name as the
13
+ # user invokes it (`"avatar"`).
14
+ # - `kind` — `:singular` (`has_one_attached`) or
15
+ # `:collection` (`has_many_attached`).
16
+ class AttachmentIndex
17
+ attr_reader :entries
18
+
19
+ def initialize(entries)
20
+ @entries = entries.freeze
21
+ freeze
22
+ end
23
+
24
+ def attachments_for(class_name)
25
+ entries[class_name.to_s]
26
+ end
27
+
28
+ def class_names = entries.keys
29
+
30
+ def empty? = entries.empty?
31
+
32
+ def self.build(rows:)
33
+ entries = rows.each_with_object({}) do |row, acc|
34
+ class_name = row.fetch(:class_name)
35
+ attachments = Array(row[:attachments]).map(&:freeze).freeze
36
+ acc[class_name] = attachments
37
+ end
38
+ new(entries.freeze)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "activestorage/attachment_discoverer"
6
+ require_relative "activestorage/attachment_index"
7
+ require_relative "activestorage/analyzer"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-activestorage — recognises `has_one_attached` /
12
+ # `has_many_attached` macros on ActiveRecord models and
13
+ # contributes attachment accessor return types so chained
14
+ # calls (`user.avatar.attached?`) route through Rigor's
15
+ # normal dispatch.
16
+ #
17
+ # ## Architecture
18
+ #
19
+ # One discovery pass per run reads the configured AR model
20
+ # paths (default `app/models/`) via the plugin's
21
+ # `IoBoundary`, walks each `.rb` file with Prism, and
22
+ # collects `has_one_attached :avatar` /
23
+ # `has_many_attached :photos` macros into an
24
+ # {AttachmentIndex} keyed by class name. The walker is
25
+ # stand-alone (mirrors `rigor-activerecord`'s
26
+ # `ModelDiscoverer`) so the plugin works even when
27
+ # `rigor-activerecord` is not loaded; when it IS loaded,
28
+ # the published `:model_index` fact (ADR-9 cross-plugin
29
+ # API) drives the same class set so the two plugins agree
30
+ # on what counts as a model.
31
+ #
32
+ # ## Configuration
33
+ #
34
+ # plugins:
35
+ # - gem: rigor-activerecord # producer of :model_index
36
+ # - gem: rigor-activestorage
37
+ # config:
38
+ # model_search_paths: ["app/models"]
39
+ #
40
+ # `model_search_paths` defaults to `["app/models"]`.
41
+ #
42
+ # The class name `Rigor::Plugin::Activestorage` (single
43
+ # capital A) matches the constant-distinguishing convention
44
+ # used by `rigor-activerecord`.
45
+ class Activestorage < Rigor::Plugin::Base
46
+ manifest(
47
+ id: "activestorage",
48
+ version: "0.1.0",
49
+ description: "Types ActiveStorage attachment macros (has_one_attached / has_many_attached) on AR models.",
50
+ config_schema: {
51
+ "model_search_paths" => :array
52
+ },
53
+ consumes: [{ plugin_id: "activerecord", name: :model_index, optional: true }]
54
+ )
55
+
56
+ DEFAULT_MODEL_SEARCH_PATHS = ["app/models"].freeze
57
+
58
+ # Cached: attachment index. Walks every `.rb` file under
59
+ # `model_search_paths` for `has_*_attached` macros.
60
+ producer :attachment_index do |_params|
61
+ rows = AttachmentDiscoverer.new(
62
+ io_boundary: io_boundary,
63
+ search_paths: @model_search_paths
64
+ ).discover
65
+ AttachmentIndex.build(rows: rows)
66
+ end
67
+
68
+ def init(_services)
69
+ @model_search_paths = Array(
70
+ config.fetch("model_search_paths", DEFAULT_MODEL_SEARCH_PATHS)
71
+ ).map(&:to_s)
72
+ @attachment_index = nil
73
+ @load_errors = []
74
+ end
75
+
76
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
77
+ index = attachment_index
78
+ return load_error_diagnostics(path) if index.nil?
79
+ return [] if index.empty?
80
+
81
+ Analyzer.new(path: path, attachment_index: index).analyze(root).diagnostics
82
+ end
83
+
84
+ # Return-type contribution: when the receiver is
85
+ # `Nominal[Model]` and the method matches a discovered
86
+ # attachment, narrow to
87
+ # `Nominal[ActiveStorage::Attached::One]` (singular) or
88
+ # `Nominal[ActiveStorage::Attached::Many]` (collection).
89
+ # The chained call (`.attached?`, `.purge`, `.url`)
90
+ # then resolves through ActiveStorage's RBS surface.
91
+ # Attachment setters (`user.avatar=`) decline — they
92
+ # take side-effecting argument types that the RBS
93
+ # surface already covers.
94
+ def flow_contribution_for(call_node:, scope:)
95
+ return nil unless call_node.is_a?(Prism::CallNode)
96
+ return nil if call_node.receiver.nil?
97
+ return nil unless call_node.arguments.nil?
98
+
99
+ index = attachment_index
100
+ return nil if index.nil? || index.empty?
101
+
102
+ receiver_type = scope.type_of(call_node.receiver)
103
+ return nil unless receiver_type.is_a?(Rigor::Type::Nominal)
104
+
105
+ attachments = index.attachments_for(receiver_type.class_name) ||
106
+ index.attachments_for("::#{receiver_type.class_name}")
107
+ return nil if attachments.nil?
108
+
109
+ attachment = attachments.find { |a| a[:name] == call_node.name.to_s }
110
+ return nil if attachment.nil?
111
+
112
+ target = case attachment[:kind]
113
+ when :singular then "ActiveStorage::Attached::One"
114
+ when :collection then "ActiveStorage::Attached::Many"
115
+ end
116
+ return nil if target.nil?
117
+
118
+ Rigor::FlowContribution.new(
119
+ return_type: Rigor::Type::Combinator.nominal_of(target),
120
+ provenance: Rigor::FlowContribution::Provenance.new(
121
+ source_family: "plugin.#{manifest.id}",
122
+ plugin_id: manifest.id,
123
+ node: call_node,
124
+ descriptor: nil
125
+ )
126
+ )
127
+ end
128
+
129
+ # @!visibility private
130
+ def attachment_index_for_spec
131
+ attachment_index
132
+ end
133
+
134
+ private
135
+
136
+ def attachment_index
137
+ return @attachment_index if @attachment_index
138
+
139
+ # Walk first so the IoBoundary's digest list captures
140
+ # the model file digests before cache_for snapshots.
141
+ AttachmentDiscoverer.new(
142
+ io_boundary: io_boundary,
143
+ search_paths: @model_search_paths
144
+ ).discover
145
+ @attachment_index = cache_for(:attachment_index, params: {}).call
146
+ rescue Plugin::AccessDeniedError => e
147
+ @load_errors << "rigor-activestorage: #{e.message}"
148
+ nil
149
+ rescue StandardError => e
150
+ @load_errors << "rigor-activestorage: discovery failed: #{e.class}: #{e.message}"
151
+ nil
152
+ end
153
+
154
+ def load_error_diagnostics(path)
155
+ @load_errors.uniq.map do |message|
156
+ Rigor::Analysis::Diagnostic.new(
157
+ path: path,
158
+ line: 1,
159
+ column: 1,
160
+ message: message,
161
+ severity: :warning,
162
+ rule: "load-error"
163
+ )
164
+ end
165
+ end
166
+ end
167
+
168
+ Rigor::Plugin.register(Activestorage)
169
+ end
170
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point matching the `rigor-<id>` gem-name convention.
4
+ # The Plugin::Loader's `require_gem!` step calls
5
+ # `require "rigor-activestorage"`, and this file loads the
6
+ # plugin's actual implementation under the `rigor/plugin/`
7
+ # namespace so the require / register pair fires.
8
+ require "rigor/plugin/activestorage"
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ # ADR-25 — a pure RBS-bundle plugin. It ships NO analyzer code:
8
+ # no `diagnostics_for_file`, no `flow_contribution_for`. Its
9
+ # whole contribution is the manifest's `signature_paths: ["sig"]`,
10
+ # which declares the bundled ActiveSupport `core_ext` RBS
11
+ # directory. `Plugin::Loader` resolves that directory against
12
+ # this gem's root and `Environment.for_project` merges it into
13
+ # the RBS environment, so the ActiveSupport core-extension
14
+ # selectors (`3.days`, `"x".squish`, `Time.current`, …) resolve.
15
+ #
16
+ # Activate it like any plugin — no path, no vendoring:
17
+ #
18
+ # # .rigor.yml
19
+ # plugins:
20
+ # - rigor-activesupport-core-ext
21
+ #
22
+ # Coverage scope and rationale: see `README.md` in this gem.
23
+ class ActivesupportCoreExt < Rigor::Plugin::Base
24
+ manifest(
25
+ id: "activesupport-core-ext",
26
+ # Bumped 2026-05-28 — added Date#midnight /
27
+ # at_midnight / beginning_of_day / end_of_day (Rails
28
+ # aliases that return Time, not Date).
29
+ version: "0.2.0",
30
+ description: "RBS bundle for the most-frequently-flagged ActiveSupport core_ext extensions.",
31
+ signature_paths: ["sig"]
32
+ )
33
+ end
34
+
35
+ Rigor::Plugin.register(ActivesupportCoreExt)
36
+ end
37
+ end