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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Rspec < Rigor::Plugin::Base
8
+ # Walks an RSpec spec file's AST and yields, for each
9
+ # describe / context block (including the outer
10
+ # `RSpec.describe`), a `Scope` value with the `let`
11
+ # and `subject` declarations recorded inside that
12
+ # scope.
13
+ #
14
+ # Scope hierarchy is preserved: each `Scope` carries
15
+ # a list of its `nested_scopes`. The analyzer uses the
16
+ # hierarchy to detect cross-scope shadowing.
17
+ #
18
+ # Recognised scope methods (when called without a
19
+ # receiver, or with `RSpec` as the receiver):
20
+ #
21
+ # - `describe` / `context` — both open a new nested
22
+ # scope.
23
+ # - `RSpec.describe` — the outermost scope.
24
+ #
25
+ # Recognised declaration methods (inside any scope,
26
+ # called without a receiver):
27
+ #
28
+ # - `let(:name) { ... }` — caches the block result
29
+ # per-example; recorded as a Declaration.
30
+ # - `let!(:name) { ... }` — same, but evaluated
31
+ # eagerly via a `before` hook; same shape for our
32
+ # purposes.
33
+ # - `subject(:name) { ... }` — special-cases name
34
+ # `:subject` when called without a name.
35
+ module ScopeWalker
36
+ SCOPE_METHODS = %i[describe context].freeze
37
+ DECLARATION_METHODS = %i[let let! subject].freeze
38
+
39
+ # @!attribute [r] kind
40
+ # `:describe`, `:context`, or `:rspec_describe` for
41
+ # the root.
42
+ # @!attribute [r] declarations
43
+ # `Array<Declaration>` declared in this scope.
44
+ # @!attribute [r] nested_scopes
45
+ # `Array<Scope>` nested under this scope.
46
+ # @!attribute [r] location
47
+ # `Prism::Location` of the call node that opened
48
+ # this scope.
49
+ Scope = Struct.new(:kind, :declarations, :nested_scopes, :location, keyword_init: true)
50
+
51
+ # @!attribute [r] name
52
+ # `Symbol` declared name (`:user`, `:subject`,
53
+ # ...).
54
+ # @!attribute [r] kind
55
+ # `:let`, `:let!`, or `:subject`.
56
+ # @!attribute [r] location
57
+ # `Prism::Location` of the call node.
58
+ # @!attribute [r] block_node
59
+ # `Prism::BlockNode` of the declaration's body.
60
+ Declaration = Struct.new(:name, :kind, :location, :block_node, keyword_init: true)
61
+
62
+ module_function
63
+
64
+ # Walks the parsed file and returns an array of
65
+ # top-level scopes (each `RSpec.describe` is a
66
+ # separate root). Files with no recognised scopes
67
+ # return an empty array.
68
+ def collect_scopes(root)
69
+ scopes = []
70
+ walk_top_level(root, scopes)
71
+ scopes
72
+ end
73
+
74
+ # Walks every scope in a tree (root + descendants)
75
+ # and yields each in turn.
76
+ def each_scope(scope, &)
77
+ yield scope
78
+ scope.nested_scopes.each { |child| each_scope(child, &) }
79
+ end
80
+
81
+ def walk_top_level(node, scopes)
82
+ return unless node.is_a?(Prism::Node)
83
+
84
+ if rspec_describe_call?(node)
85
+ scopes << build_scope(node, kind: :rspec_describe)
86
+ else
87
+ node.compact_child_nodes.each { |child| walk_top_level(child, scopes) }
88
+ end
89
+ end
90
+
91
+ # Returns true for `RSpec.describe ... do |...| ...
92
+ # end` calls.
93
+ def rspec_describe_call?(node)
94
+ return false unless node.is_a?(Prism::CallNode)
95
+ return false unless node.name == :describe
96
+ return false unless node.block.is_a?(Prism::BlockNode)
97
+
98
+ receiver_name = constant_name(node.receiver)
99
+ %w[RSpec ::RSpec].include?(receiver_name)
100
+ end
101
+
102
+ # Returns true for `describe ... do ... end` /
103
+ # `context ... do ... end` (called without an
104
+ # explicit receiver — `RSpec.describe` is handled
105
+ # separately by `rspec_describe_call?`).
106
+ def nested_scope_call?(node)
107
+ node.is_a?(Prism::CallNode) &&
108
+ SCOPE_METHODS.include?(node.name) &&
109
+ node.block.is_a?(Prism::BlockNode) &&
110
+ node.receiver.nil?
111
+ end
112
+
113
+ def declaration_call?(node)
114
+ node.is_a?(Prism::CallNode) &&
115
+ DECLARATION_METHODS.include?(node.name) &&
116
+ node.receiver.nil?
117
+ end
118
+
119
+ # Constructs a Scope from a describe / context /
120
+ # RSpec.describe call node. Walks the block body
121
+ # for declarations + nested scopes.
122
+ def build_scope(call_node, kind:)
123
+ declarations = []
124
+ nested = []
125
+ (call_node.block.body&.compact_child_nodes || []).each do |child|
126
+ classify_child(child, declarations, nested)
127
+ end
128
+
129
+ Scope.new(
130
+ kind: kind,
131
+ declarations: declarations,
132
+ nested_scopes: nested,
133
+ location: call_node.location
134
+ )
135
+ end
136
+
137
+ def classify_child(child, declarations, nested)
138
+ if declaration_call?(child)
139
+ decl = build_declaration(child)
140
+ declarations << decl if decl
141
+ elsif nested_scope_call?(child)
142
+ nested << build_scope(child, kind: child.name)
143
+ end
144
+ end
145
+
146
+ def build_declaration(call_node)
147
+ first_arg = call_node.arguments&.arguments&.first
148
+ # `subject(&block)` (no name) defaults to the
149
+ # implicit subject; record it as `:subject`.
150
+ if first_arg.nil?
151
+ return nil unless call_node.name == :subject
152
+
153
+ return Declaration.new(
154
+ name: :subject,
155
+ kind: call_node.name,
156
+ location: call_node.location,
157
+ block_node: call_node.block
158
+ )
159
+ end
160
+ return nil unless first_arg.is_a?(Prism::SymbolNode)
161
+
162
+ Declaration.new(
163
+ name: first_arg.unescaped.to_sym,
164
+ kind: call_node.name,
165
+ location: call_node.location,
166
+ block_node: call_node.block
167
+ )
168
+ end
169
+
170
+ def constant_name(node)
171
+ case node
172
+ when nil then nil
173
+ when Prism::ConstantReadNode then node.name.to_s
174
+ when Prism::ConstantPathNode
175
+ parts = []
176
+ current = node
177
+ while current.is_a?(Prism::ConstantPathNode)
178
+ parts.unshift(current.name.to_s)
179
+ current = current.parent
180
+ end
181
+ case current
182
+ when nil then "::#{parts.join('::')}"
183
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "rspec/scope_walker"
6
+ require_relative "rspec/analyzer"
7
+ require_relative "rspec/matcher_analyzer"
8
+ require_relative "rspec/let_scope_index"
9
+ require_relative "rspec/let_type_resolver"
10
+
11
+ module Rigor
12
+ module Plugin
13
+ # rigor-rspec — validates RSpec `let` / `subject`
14
+ # declarations within each describe / context scope.
15
+ #
16
+ # Tier 3A of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
17
+ # Deliberately scoped — the roadmap describes a much
18
+ # larger plugin (let-typo detection in `it` bodies,
19
+ # `expect(x).to receive(:method)` mock-target
20
+ # validation). Both are out of scope for v0.1.0; this
21
+ # plugin ships the two checks that have the lowest
22
+ # false-positive risk:
23
+ #
24
+ # 1. **Duplicate `let` / `subject` declarations** in
25
+ # the same scope (`warning`). RSpec's runtime lets
26
+ # the last declaration win, so the first one is
27
+ # silently shadowed — almost always a copy-paste
28
+ # bug.
29
+ # 2. **Self-referencing `let` / `subject`** — calling
30
+ # the declared name *inside* its own block body
31
+ # (`error`). At runtime this infinite-loops; users
32
+ # typically meant to call a different method or
33
+ # forgot to introduce a `super`.
34
+ #
35
+ # ## Configuration
36
+ #
37
+ # No knobs in v0.1.0. The plugin walks every analysed
38
+ # file looking for `RSpec.describe ... do` blocks; spec
39
+ # files outside the project's `paths:` are not scanned.
40
+ #
41
+ # ## Limitations (v0.1.0)
42
+ #
43
+ # - **No let-typo detection.** Detecting an `it`
44
+ # block's reference to a misspelled `let` name
45
+ # requires resolving every method call inside the
46
+ # block against the let scope chain, the included
47
+ # modules, the matchers DSL, and helper methods.
48
+ # Reliable diagnostics here need a much heavier
49
+ # walker — see the README's `Future direction`.
50
+ # - **No mock-target validation.**
51
+ # `expect(x).to receive(:nme)` validating against
52
+ # `x`'s methods is a separate slice; it overlaps with
53
+ # the engine's general method-existence
54
+ # diagnostics and needs careful coordination to avoid
55
+ # double-firing.
56
+ # - **No shared-context resolution.** `include_context`,
57
+ # `shared_context`, and `it_behaves_like` are
58
+ # recognised as scope-opening calls but their
59
+ # declarations are not pulled into the host scope.
60
+ # - **Constant validation is not done here.**
61
+ # `RSpec.describe SomeClass do` does not validate
62
+ # `SomeClass`; the engine's `inference.unresolved-constant`
63
+ # already catches that.
64
+ class Rspec < Rigor::Plugin::Base
65
+ manifest(
66
+ id: "rspec",
67
+ version: "0.3.0",
68
+ description: "Validates RSpec `let` / `subject` declarations within each scope; " \
69
+ "narrows expect(x).to <matcher> assertions downstream in `it` bodies; " \
70
+ "binds let / subject locals to their inferred return type (Pillar 2 " \
71
+ "Slice 2) — `let(:user) { User.new(...) }` / `let(:user) { create(:user) }` / " \
72
+ "`subject { described_class.new(...) }`.",
73
+ consumes: [
74
+ { plugin_id: "factorybot", name: :factory_index, optional: true }
75
+ ]
76
+ )
77
+
78
+ def init(services)
79
+ @services = services
80
+ @factory_index_resolved = false
81
+ @factory_index = nil
82
+ # Per-path `LetScopeIndex` cache. The plugin's
83
+ # `flow_contribution_for` is called for every call
84
+ # node the dispatcher visits; building the index once
85
+ # per file is essential for performance.
86
+ @let_index_cache = {}
87
+ end
88
+
89
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
90
+ # Build the let-scope index for this file while we
91
+ # have the parsed root in hand — `flow_contribution_for`
92
+ # picks it up from `@let_index_cache` keyed on path.
93
+ @let_index_cache[path] ||= LetScopeIndex.build(root)
94
+ Analyzer.diagnose(path: path, root: root).map { |diag| build_diagnostic(diag) }
95
+ end
96
+
97
+ # Pillar 2 Slice 1 — spec-derived flow facts from RSpec
98
+ # matcher assertions (six-matcher floor: `be_a`,
99
+ # `be_kind_of`, `be_instance_of`, `be_nil`, `eq(literal)`,
100
+ # `eql(literal)`; `match(/regex/)`; `not_to` / `to_not`).
101
+ #
102
+ # Pillar 2 Slice 2 (v0.3.0) — additionally binds local
103
+ # reads in `it` / spec bodies to their `let(:name) { ... }`
104
+ # block's inferred return type. Composes with Slice 1:
105
+ # a matcher narrowing fires after the let binding.
106
+ def flow_contribution_for(call_node:, scope:)
107
+ matcher = MatcherAnalyzer.contribution_for(call_node, environment: scope&.environment)
108
+ return matcher if matcher
109
+
110
+ let_binding_contribution(call_node, scope)
111
+ end
112
+
113
+ private
114
+
115
+ # Pillar 2 Slice 2 — when the call node is a no-receiver
116
+ # method call (`user`, `subject`, etc.) inside an RSpec
117
+ # `describe` block whose lets include a matching name,
118
+ # return a `FlowContribution(return_type: <inferred>)`.
119
+ def let_binding_contribution(call_node, scope)
120
+ return nil if scope.nil?
121
+ return nil unless candidate_call?(call_node)
122
+
123
+ index = let_scope_index_for(scope.source_path)
124
+ return nil if index.nil?
125
+
126
+ line = call_node.location.start_line
127
+ block_node = index.let_block_at(line, call_node.name)
128
+ return nil if block_node.nil?
129
+
130
+ describe_const = index.describe_const_at(line)
131
+ type = LetTypeResolver.resolve(
132
+ block_node,
133
+ describe_const: describe_const,
134
+ factory_index: factory_index_or_nil,
135
+ environment: scope.environment
136
+ )
137
+ return nil if type.nil?
138
+
139
+ Rigor::FlowContribution.new(return_type: type)
140
+ end
141
+
142
+ def candidate_call?(call_node)
143
+ call_node.is_a?(Prism::CallNode) &&
144
+ call_node.receiver.nil? &&
145
+ call_node.block.nil? &&
146
+ # Calls with arguments are matcher / DSL invocations,
147
+ # not let-bound name reads. `subject` / `user` etc.
148
+ # without args are the implicit-method-call shape RSpec
149
+ # uses to expose let / subject in `it` bodies.
150
+ (call_node.arguments.nil? || call_node.arguments.arguments.empty?)
151
+ end
152
+
153
+ def let_scope_index_for(path)
154
+ return nil if path.nil?
155
+ return @let_index_cache[path] if @let_index_cache.key?(path)
156
+
157
+ @let_index_cache[path] = build_let_scope_index(path)
158
+ end
159
+
160
+ def build_let_scope_index(path)
161
+ source = io_boundary.read_file(path)
162
+ parse_result = Prism.parse(source)
163
+ return nil unless parse_result.errors.empty?
164
+
165
+ LetScopeIndex.build(parse_result.value)
166
+ rescue Rigor::Plugin::AccessDeniedError, Errno::ENOENT
167
+ nil
168
+ end
169
+
170
+ def factory_index_or_nil
171
+ return @factory_index if @factory_index_resolved
172
+
173
+ @factory_index = @services&.fact_store&.read(plugin_id: "factorybot", name: :factory_index)
174
+ @factory_index_resolved = true
175
+ @factory_index
176
+ end
177
+
178
+ def build_diagnostic(diag)
179
+ Rigor::Analysis::Diagnostic.new(
180
+ path: diag.path, line: diag.line, column: diag.column,
181
+ message: diag.message, severity: diag.severity, rule: diag.rule
182
+ )
183
+ end
184
+ end
185
+
186
+ Rigor::Plugin.register(Rspec)
187
+ end
188
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/rspec"
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "http_status_codes"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class RspecRails < Rigor::Plugin::Base
10
+ # Validates `expect(response).to have_http_status(arg)` /
11
+ # the negated `.not_to have_http_status(arg)` and the
12
+ # bare matcher form `should have_http_status(arg)`.
13
+ #
14
+ # Recognised argument shapes:
15
+ #
16
+ # - **IntegerNode**: must be in `100..599`. Numbers outside
17
+ # that range fire `have_http_status.out-of-range`.
18
+ # - **SymbolNode**: must be one of the known
19
+ # `Rack::Utils::SYMBOL_TO_STATUS_CODE` keys OR a Rails
20
+ # status-group alias (`:success` / `:successful` /
21
+ # `:missing` / `:redirect` / `:error` / `:client_error` /
22
+ # `:server_error` / `:informational`). Unknown symbols
23
+ # fire `have_http_status.unknown-symbol` (typo-flavoured).
24
+ # - **StringNode**: passed through silently (a literal
25
+ # `"200"` is accepted by Rails at runtime; we don't
26
+ # second-guess the user's intent for the String form).
27
+ # - **Anything else** (variable, method call, computed
28
+ # expression): skipped — the plugin can't statically
29
+ # prove the runtime value.
30
+ #
31
+ # Walks every `Prism::CallNode` looking for matcher
32
+ # invocations rather than only the `expect(...).to ...`
33
+ # chain because `have_http_status` is also used as a
34
+ # plain matcher inside Rails' `assert_response` shim and
35
+ # in shared-context bodies; the diagnostic is the same
36
+ # regardless of the surrounding chain.
37
+ module HaveHttpStatusAnalyzer
38
+ Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
39
+
40
+ MATCHER_NAME = :have_http_status
41
+
42
+ module_function
43
+
44
+ # @param path [String]
45
+ # @param root [Prism::Node]
46
+ # @return [Array<Diagnostic>]
47
+ def diagnose(path:, root:)
48
+ diagnostics = []
49
+ walk(root) do |call_node|
50
+ diagnostic = diagnostic_for(call_node, path)
51
+ diagnostics << diagnostic if diagnostic
52
+ end
53
+ diagnostics
54
+ end
55
+
56
+ def walk(node, &)
57
+ return unless node.is_a?(Prism::Node)
58
+
59
+ yield node if call_to_matcher?(node)
60
+ node.compact_child_nodes.each { |child| walk(child, &) }
61
+ end
62
+
63
+ # `have_http_status` is a matcher — called either with
64
+ # no receiver (`have_http_status(200)` inside `.to(...)`)
65
+ # or with a receiver chain we don't care about here.
66
+ # Either way we want the call node whose name is
67
+ # `have_http_status` and that carries exactly one
68
+ # positional argument.
69
+ def call_to_matcher?(node)
70
+ node.is_a?(Prism::CallNode) &&
71
+ node.name == MATCHER_NAME &&
72
+ single_positional?(node)
73
+ end
74
+
75
+ def single_positional?(call_node)
76
+ args = call_node.arguments&.arguments || []
77
+ args.size == 1
78
+ end
79
+
80
+ def diagnostic_for(call_node, path)
81
+ arg = call_node.arguments.arguments.first
82
+ case arg
83
+ when Prism::IntegerNode then integer_diagnostic(call_node, path, arg)
84
+ when Prism::SymbolNode then symbol_diagnostic(call_node, path, arg)
85
+ end
86
+ end
87
+
88
+ def integer_diagnostic(call_node, path, integer_node)
89
+ value = integer_node.value
90
+ return nil if HttpStatusCodes::VALID_NUMERIC_RANGE.cover?(value)
91
+
92
+ build_diagnostic(
93
+ call_node, path,
94
+ rule: "have_http_status.out-of-range",
95
+ message: "have_http_status(#{value}) is outside the valid HTTP status " \
96
+ "range #{HttpStatusCodes::VALID_NUMERIC_RANGE}"
97
+ )
98
+ end
99
+
100
+ def symbol_diagnostic(call_node, path, symbol_node)
101
+ sym = symbol_node.unescaped.to_sym
102
+ return nil if HttpStatusCodes::KNOWN_SYMBOLS.include?(sym)
103
+
104
+ build_diagnostic(
105
+ call_node, path,
106
+ rule: "have_http_status.unknown-symbol",
107
+ message: "have_http_status(:#{sym}) is not a recognised HTTP status symbol " \
108
+ "(see Rack::Utils::SYMBOL_TO_STATUS_CODE) or Rails status-group " \
109
+ "alias (:success / :successful / :missing / :redirect / :error / " \
110
+ ":client_error / :server_error / :informational)"
111
+ )
112
+ end
113
+
114
+ def build_diagnostic(call_node, path, rule:, message:)
115
+ location = call_node.message_loc || call_node.location
116
+ Diagnostic.new(
117
+ path: path,
118
+ line: location.start_line,
119
+ column: location.start_column + 1,
120
+ severity: :warning,
121
+ rule: rule,
122
+ message: message
123
+ )
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class RspecRails < Rigor::Plugin::Base
6
+ # Curated set of HTTP-status symbols `have_http_status`
7
+ # accepts. Derived from `Rack::Utils::SYMBOL_TO_STATUS_CODE`
8
+ # (Rack 3.x snapshot) plus Rails' status-group aliases
9
+ # accepted by `ActionDispatch::TestResponse`.
10
+ #
11
+ # Vendored as a frozen Set so the plugin has no
12
+ # `rack` / `actionpack` runtime dependency — checking
13
+ # against a static catalogue is intentional: a future
14
+ # Rack release that adds a new alias would require
15
+ # bumping this constant, but the false-positive risk is
16
+ # bounded (the diagnostic just suggests a typo when in
17
+ # fact the symbol is newly-added).
18
+ module HttpStatusCodes
19
+ SYMBOL_TO_STATUS_CODE = {
20
+ # 1xx Informational
21
+ continue: 100, switching_protocols: 101, processing: 102, early_hints: 103,
22
+ # 2xx Success
23
+ ok: 200, created: 201, accepted: 202, non_authoritative_information: 203,
24
+ no_content: 204, reset_content: 205, partial_content: 206, multi_status: 207,
25
+ already_reported: 208, im_used: 226,
26
+ # 3xx Redirection
27
+ multiple_choices: 300, moved_permanently: 301, found: 302, see_other: 303,
28
+ not_modified: 304, use_proxy: 305, temporary_redirect: 307,
29
+ permanent_redirect: 308,
30
+ # 4xx Client Error
31
+ bad_request: 400, unauthorized: 401, payment_required: 402, forbidden: 403,
32
+ not_found: 404, method_not_allowed: 405, not_acceptable: 406,
33
+ proxy_authentication_required: 407, request_timeout: 408, conflict: 409,
34
+ gone: 410, length_required: 411, precondition_failed: 412,
35
+ payload_too_large: 413, uri_too_long: 414, unsupported_media_type: 415,
36
+ range_not_satisfiable: 416, expectation_failed: 417, misdirected_request: 421,
37
+ unprocessable_entity: 422, unprocessable_content: 422, locked: 423,
38
+ failed_dependency: 424, too_early: 425, upgrade_required: 426,
39
+ precondition_required: 428, too_many_requests: 429,
40
+ request_header_fields_too_large: 431, unavailable_for_legal_reasons: 451,
41
+ # 5xx Server Error
42
+ internal_server_error: 500, not_implemented: 501, bad_gateway: 502,
43
+ service_unavailable: 503, gateway_timeout: 504, http_version_not_supported: 505,
44
+ variant_also_negotiates: 506, insufficient_storage: 507, loop_detected: 508,
45
+ not_extended: 510, network_authentication_required: 511,
46
+ # Rails status-group aliases (see
47
+ # ActionDispatch::TestResponse RESPONSE_PREDICATES)
48
+ informational: :informational_group, success: :success_group,
49
+ successful: :success_group, redirect: :redirect_group,
50
+ client_error: :client_error_group, missing: :client_error_group,
51
+ server_error: :server_error_group, error: :server_error_group
52
+ }.freeze
53
+
54
+ KNOWN_SYMBOLS = SYMBOL_TO_STATUS_CODE.keys.to_set.freeze
55
+
56
+ VALID_NUMERIC_RANGE = (100..599)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "rspec_rails/have_http_status_analyzer"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ # rigor-rspec-rails — validates rspec-rails matchers whose
10
+ # arguments are statically checkable.
11
+ #
12
+ # v0.1.0 covers `have_http_status(int_or_symbol)`:
13
+ #
14
+ # - Integer: must be in `100..599`.
15
+ # - Symbol: must be one of the
16
+ # `Rack::Utils::SYMBOL_TO_STATUS_CODE` keys or one of
17
+ # Rails' status-group aliases (`:success`, `:successful`,
18
+ # `:missing`, `:redirect`, `:error`, `:client_error`,
19
+ # `:server_error`, `:informational`).
20
+ #
21
+ # ## Why this is a separate plugin from rigor-rspec
22
+ #
23
+ # `rigor-rspec` handles the **type-narrowing** matchers
24
+ # (`be_a`, `be_kind_of`, `be_nil`, `eq(literal)`, etc.) —
25
+ # ones that refine a local's static type so downstream
26
+ # calls in the same `it` body resolve at the narrowed
27
+ # type. `rigor-rspec-rails` handles the **behavioral**
28
+ # matchers — ones that assert runtime state (HTTP status,
29
+ # rendered template, route shape) without narrowing a
30
+ # type. The two are activated independently in `.rigor.yml`.
31
+ #
32
+ # ## Deferred matchers
33
+ #
34
+ # - `render_template(...)` — overlaps with
35
+ # `rigor-actionpack`'s render-target validation
36
+ # (`render :show` against the view tree). A separate
37
+ # slice would coordinate the two to avoid
38
+ # double-firing.
39
+ # - `route_to(...)` / `redirect_to(...)` — needs the
40
+ # routes table from `rigor-rails-routes`
41
+ # (`:helper_table` cross-plugin fact). Future slice.
42
+ # - `have_enqueued_job(JobClass)` /
43
+ # `have_enqueued_mail(MailerClass)` — class-existence
44
+ # check overlaps with engine's
45
+ # `inference.unresolved-constant`; queued behind a
46
+ # decision on which surface owns the diagnostic.
47
+ # - `have_received(:method)` — overlaps with engine's
48
+ # `call.undefined-method`; same coordination question.
49
+ # - `be_routable` — needs routes table.
50
+ # - `match_response_schema(...)` (rswag / OpenAPI) — out
51
+ # of scope, separate plugin.
52
+ class RspecRails < Rigor::Plugin::Base
53
+ manifest(
54
+ id: "rspec-rails",
55
+ version: "0.1.0",
56
+ description: "Validates rspec-rails behavioral matchers (have_http_status floor)."
57
+ )
58
+
59
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
60
+ HaveHttpStatusAnalyzer.diagnose(path: path, root: root).map { |diag| build_diagnostic(diag) }
61
+ end
62
+
63
+ private
64
+
65
+ def build_diagnostic(diag)
66
+ Rigor::Analysis::Diagnostic.new(
67
+ path: diag.path, line: diag.line, column: diag.column,
68
+ message: diag.message, severity: diag.severity, rule: diag.rule
69
+ )
70
+ end
71
+ end
72
+
73
+ Rigor::Plugin.register(RspecRails)
74
+ end
75
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/rspec_rails"