rigortype 0.1.9 → 0.1.11

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 (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -0,0 +1,25 @@
1
+ # RBS overlay for dry-validation core. Users add this directory
2
+ # to their `.rigor.yml`'s `signature_paths:` so
3
+ # `contract.call(input).to_h` chains type cleanly.
4
+ #
5
+ # This is the slice-1 floor — `Contract#call` returns the generic
6
+ # `Result`. Slice 2 (deferred) will refine `Result#to_h` per-contract
7
+ # by consuming the `:dry_schema_table` fact published by
8
+ # rigor-dry-schema.
9
+
10
+ module Dry
11
+ module Validation
12
+ class Contract
13
+ def call: (Hash[Symbol, untyped]) -> Result
14
+ | (untyped) -> Result
15
+ end
16
+
17
+ class Result
18
+ def success?: () -> bool
19
+ def failure?: () -> bool
20
+ def to_h: () -> Hash[Symbol, untyped]
21
+ def errors: () -> untyped
22
+ def []: (Symbol) -> untyped
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+ require "prism"
5
+
6
+ module Rigor
7
+ module Plugin
8
+ class Factorybot < Rigor::Plugin::Base
9
+ # Per-file walker — visits every `FactoryBot.<entry>(...)`
10
+ # call (and the `FactoryGirl` legacy alias) and validates
11
+ # the factory name + the keyword-argument attribute keys
12
+ # against the per-run {FactoryIndex}.
13
+ #
14
+ # Recognised entry methods cover the canonical create /
15
+ # build / build_stubbed / attributes_for family; the same
16
+ # validation applies to every entry (the runtime semantics
17
+ # differ — one persists, one returns a hash — but the
18
+ # call-site shape is identical from the static check's
19
+ # perspective).
20
+ module Analyzer
21
+ ENTRY_METHODS = %i[create build build_stubbed attributes_for create_list build_list build_stubbed_list].freeze
22
+
23
+ Diagnostic = Data.define(:path, :line, :column, :message, :severity, :rule)
24
+
25
+ module_function
26
+
27
+ def diagnose(path:, root:, factory_index:, model_index: nil)
28
+ diagnostics = []
29
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: factory_index.names)
30
+
31
+ walk_entry_calls(root) do |call_node|
32
+ factory_name = first_positional_symbol_or_string(call_node)
33
+ next if factory_name.nil?
34
+
35
+ entry = factory_index.find(factory_name)
36
+ diagnostics.concat(
37
+ diagnostics_for_call(path, call_node, factory_name, entry, spell_checker, model_index)
38
+ )
39
+ end
40
+
41
+ diagnostics
42
+ end
43
+
44
+ # Walk the AST yielding only call nodes whose receiver
45
+ # is `FactoryBot` (or the `FactoryGirl` legacy alias)
46
+ # and whose method name is in {ENTRY_METHODS}.
47
+ def walk_entry_calls(node, &)
48
+ return unless node.is_a?(Prism::Node)
49
+
50
+ yield node if entry_call?(node)
51
+ node.compact_child_nodes.each { |child| walk_entry_calls(child, &) }
52
+ end
53
+
54
+ def entry_call?(node)
55
+ return false unless node.is_a?(Prism::CallNode)
56
+ return false unless ENTRY_METHODS.include?(node.name)
57
+
58
+ factorybot_receiver?(node.receiver)
59
+ end
60
+
61
+ def factorybot_receiver?(receiver)
62
+ return false unless receiver.is_a?(Prism::ConstantReadNode) ||
63
+ receiver.is_a?(Prism::ConstantPathNode)
64
+
65
+ name = case receiver
66
+ when Prism::ConstantReadNode then receiver.name.to_s
67
+ when Prism::ConstantPathNode then receiver.name.to_s
68
+ end
69
+ %w[FactoryBot FactoryGirl].include?(name)
70
+ end
71
+
72
+ def first_positional_symbol_or_string(call_node)
73
+ first_arg = call_node.arguments&.arguments&.first
74
+ case first_arg
75
+ when Prism::SymbolNode then first_arg.value
76
+ when Prism::StringNode then first_arg.unescaped
77
+ end
78
+ end
79
+
80
+ def diagnostics_for_call(path, call_node, factory_name, entry, spell_checker, model_index)
81
+ return [unknown_factory_diagnostic(path, call_node, factory_name, spell_checker)] if entry.nil?
82
+
83
+ unknown_attribute_diagnostics(path, call_node, entry, model_index) +
84
+ [factory_call_diagnostic(path, call_node, factory_name, entry)]
85
+ end
86
+
87
+ # The keyword-argument attribute keys come from the
88
+ # trailing `Prism::KeywordHashNode` (Ruby's
89
+ # `name: "value"` syntax). Each AssocNode whose key is
90
+ # a `Prism::SymbolNode` is treated as a literal
91
+ # attribute reference.
92
+ #
93
+ # Phase 1 (c) — when `model_index` (the cross-plugin
94
+ # `:model_index` fact published by rigor-activerecord)
95
+ # is present, the effective accepted key set is the
96
+ # UNION of the factory's declared attributes plus the
97
+ # corresponding model's columns. FactoryBot's runtime
98
+ # accepts any AR attribute regardless of whether the
99
+ # factory declared it, so the cross-check broadens the
100
+ # acceptance accordingly.
101
+ def unknown_attribute_diagnostics(path, call_node, entry, model_index)
102
+ accepted_keys, suggestion_dictionary = effective_keys(entry, model_index)
103
+ attr_spell_checker = DidYouMean::SpellChecker.new(dictionary: suggestion_dictionary)
104
+ attribute_assoc_nodes(call_node).filter_map do |assoc|
105
+ next unless assoc.key.is_a?(Prism::SymbolNode)
106
+
107
+ attr_name = assoc.key.value
108
+ next if accepted_keys.include?(attr_name)
109
+
110
+ unknown_attribute_diagnostic(path, assoc, entry, attr_name, attr_spell_checker)
111
+ end
112
+ end
113
+
114
+ def effective_keys(entry, model_index)
115
+ factory_keys = entry.attribute_names
116
+ return [factory_keys, factory_keys] if model_index.nil?
117
+
118
+ model_class = model_class_for(entry.name)
119
+ model_entry = model_index[model_class]
120
+ return [factory_keys, factory_keys] if model_entry.nil?
121
+
122
+ model_columns = model_entry[:columns] || []
123
+ [(factory_keys + model_columns).uniq.freeze, (factory_keys + model_columns).uniq.freeze]
124
+ end
125
+
126
+ # Convention: `:user` → `User`, `:order_item` →
127
+ # `OrderItem`. Mirrors rigor-actionpack Phase 1's
128
+ # convention; namespaced models (`:admin_user` →
129
+ # `Admin::User`) are deferred.
130
+ def model_class_for(factory_name)
131
+ factory_name.to_s.split("_").map(&:capitalize).join
132
+ end
133
+
134
+ def attribute_assoc_nodes(call_node)
135
+ args = call_node.arguments&.arguments || []
136
+ last = args.last
137
+ return [] unless last.is_a?(Prism::KeywordHashNode)
138
+
139
+ last.elements.grep(Prism::AssocNode)
140
+ end
141
+
142
+ def factory_call_diagnostic(path, call_node, factory_name, entry)
143
+ loc = call_node.message_loc || call_node.location
144
+ attrs = entry.attribute_names.empty? ? "(no attributes)" : entry.attribute_names.join(", ")
145
+ Diagnostic.new(
146
+ path: path, line: loc.start_line, column: loc.start_column + 1,
147
+ message: "FactoryBot.#{call_node.name}(:#{factory_name}) — declared attributes: #{attrs}.",
148
+ severity: :info, rule: "factory-call"
149
+ )
150
+ end
151
+
152
+ def unknown_factory_diagnostic(path, call_node, factory_name, spell_checker)
153
+ loc = call_node.message_loc || call_node.location
154
+ base = "FactoryBot.#{call_node.name}(:#{factory_name}) — factory not declared in any " \
155
+ "factory_search_paths file."
156
+ suggestion = spell_checker.correct(factory_name).first
157
+ message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
158
+ Diagnostic.new(
159
+ path: path, line: loc.start_line, column: loc.start_column + 1,
160
+ message: message, severity: :error, rule: "unknown-factory"
161
+ )
162
+ end
163
+
164
+ def unknown_attribute_diagnostic(path, assoc, entry, attr_name, spell_checker)
165
+ loc = assoc.key.location
166
+ base = "FactoryBot factory `:#{entry.name}` has no declared attribute `:#{attr_name}`."
167
+ suggestion = spell_checker.correct(attr_name).first
168
+ message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
169
+ Diagnostic.new(
170
+ path: path, line: loc.start_line, column: loc.start_column + 1,
171
+ message: message, severity: :error, rule: "unknown-attribute"
172
+ )
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "factory_index"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class Factorybot < Rigor::Plugin::Base
10
+ # Walks `factory_search_paths` and parses each `.rb` file
11
+ # into a {FactoryIndex}. The search-path list contains
12
+ # both directory paths (recursively walked) and direct
13
+ # file paths (read once); the typical default
14
+ # `["spec/factories", "spec/factories.rb"]` covers both
15
+ # the multi-file convention RSpec uses today and the
16
+ # legacy single-file form.
17
+ #
18
+ # The walker recognises:
19
+ #
20
+ # - `factory :users do ... end` — symbol form
21
+ # - `factory "users" do ... end` — string form
22
+ # - `factory :users, aliases: [:author] do ... end` — alias form
23
+ #
24
+ # Inside a factory block, attribute declarations come in
25
+ # several shapes. Phase 1 (a) recognises the literal-name
26
+ # forms only (Symbol arg / String arg):
27
+ #
28
+ # - `name { "Alice" }` — implicit attribute via
29
+ # `method_missing` with a block (FactoryBot's modern
30
+ # syntax)
31
+ # - `name "Alice"` — implicit attribute via
32
+ # `method_missing` with a positional argument (legacy)
33
+ # - `add_attribute(:name) { "Alice" }` — the explicit
34
+ # form
35
+ #
36
+ # Sequences (`sequence(:email) { ... }`), associations
37
+ # (`association :author`), traits, and parent / child
38
+ # relationships are deferred to later slices.
39
+ class FactoryDiscoverer
40
+ def initialize(io_boundary:, search_paths:)
41
+ @io_boundary = io_boundary
42
+ @search_paths = search_paths
43
+ end
44
+
45
+ # @return [FactoryIndex]
46
+ def discover
47
+ entries = {}
48
+ ruby_files_under(@search_paths).each do |path|
49
+ harvest(path, entries)
50
+ end
51
+ FactoryIndex.new(entries.freeze)
52
+ end
53
+
54
+ private
55
+
56
+ def ruby_files_under(roots)
57
+ roots.flat_map do |root|
58
+ absolute = File.expand_path(root)
59
+ if File.file?(absolute)
60
+ [absolute]
61
+ elsif File.directory?(absolute)
62
+ Dir.glob(File.join(absolute, "**", "*.rb"))
63
+ else
64
+ []
65
+ end
66
+ end
67
+ end
68
+
69
+ def harvest(path, entries)
70
+ contents = @io_boundary.read_file(path)
71
+ parse_result = Prism.parse(contents)
72
+ return unless parse_result.errors.empty?
73
+
74
+ walk_for_factories(parse_result.value) do |factory_name, attribute_names, model_class|
75
+ entries[factory_name] = FactoryIndex::Entry.new(
76
+ name: factory_name,
77
+ attribute_names: attribute_names.uniq.freeze,
78
+ model_class: model_class
79
+ )
80
+ end
81
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
82
+ nil
83
+ end
84
+
85
+ # Yields `(factory_name, [attribute_names])` for every
86
+ # `factory :name do ... end` call discovered in the
87
+ # subtree. The walker recurses into top-level wrapping
88
+ # blocks (`FactoryBot.define do ... end`) and into
89
+ # arbitrary container nodes so factories inside `module`
90
+ # / `class` blocks are still picked up.
91
+ def walk_for_factories(node, &)
92
+ return unless node.is_a?(Prism::Node)
93
+
94
+ if factory_call?(node)
95
+ visit_factory(node, &)
96
+ return
97
+ end
98
+ node.compact_child_nodes.each { |child| walk_for_factories(child, &) }
99
+ end
100
+
101
+ def factory_call?(node)
102
+ node.is_a?(Prism::CallNode) && node.name == :factory && node.receiver.nil?
103
+ end
104
+
105
+ def visit_factory(call_node)
106
+ factory_name = literal_name_arg(call_node)
107
+ return if factory_name.nil?
108
+
109
+ attribute_names = collect_attribute_names(call_node.block)
110
+ model_class = factory_model_class(call_node, factory_name)
111
+ yield factory_name, attribute_names, model_class
112
+ end
113
+
114
+ def literal_name_arg(call_node)
115
+ first_arg = call_node.arguments&.arguments&.first
116
+ case first_arg
117
+ when Prism::SymbolNode then first_arg.value
118
+ when Prism::StringNode then first_arg.unescaped
119
+ end
120
+ end
121
+
122
+ # Pillar 2 Slice 3 — resolve the model class name for
123
+ # the factory. Three sources, in priority order:
124
+ #
125
+ # 1. Explicit `class: <Const>` keyword arg —
126
+ # ConstantReadNode / ConstantPathNode value.
127
+ # 2. Explicit `class: "<name>"` keyword arg — String
128
+ # value (supports `"Admin::User"`).
129
+ # 3. Inflected from the factory name — `:user` →
130
+ # `"User"`, `:admin_user` → `"AdminUser"`. The
131
+ # factory name is already singular by FactoryBot
132
+ # convention, so we only need camelization.
133
+ #
134
+ # Returns a String (the canonical class name).
135
+ def factory_model_class(call_node, factory_name)
136
+ explicit = explicit_class_option(call_node)
137
+ return explicit if explicit
138
+
139
+ camelize(factory_name)
140
+ end
141
+
142
+ def explicit_class_option(call_node)
143
+ kwargs = factory_keyword_args(call_node)
144
+ return nil if kwargs.nil?
145
+
146
+ class_pair = kwargs.elements.find do |elem|
147
+ elem.is_a?(Prism::AssocNode) &&
148
+ elem.key.is_a?(Prism::SymbolNode) &&
149
+ elem.key.value == "class"
150
+ end
151
+ return nil if class_pair.nil?
152
+
153
+ render_class_value(class_pair.value)
154
+ end
155
+
156
+ def factory_keyword_args(call_node)
157
+ args = call_node.arguments&.arguments || []
158
+ last = args.last
159
+ last.is_a?(Prism::KeywordHashNode) || last.is_a?(Prism::HashNode) ? last : nil
160
+ end
161
+
162
+ def render_class_value(node)
163
+ case node
164
+ when Prism::ConstantReadNode then node.name.to_s
165
+ when Prism::ConstantPathNode then render_constant_path(node)
166
+ when Prism::StringNode then node.unescaped
167
+ end
168
+ end
169
+
170
+ def render_constant_path(node)
171
+ parts = []
172
+ current = node
173
+ while current.is_a?(Prism::ConstantPathNode)
174
+ parts.unshift(current.name.to_s)
175
+ current = current.parent
176
+ end
177
+ case current
178
+ when nil then "::#{parts.join('::')}"
179
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
180
+ end
181
+ end
182
+
183
+ # Pure-Ruby camelize for the factory-name fallback.
184
+ # `user` → `User`, `blog_post` → `BlogPost`, `admin_user`
185
+ # → `AdminUser`. Factory names with `/` separators
186
+ # (`admin/user`) camelize per-segment and join with `::`
187
+ # (`Admin::User`), mirroring Rails inflection.
188
+ def camelize(snake)
189
+ snake.to_s.split("/").map do |segment|
190
+ segment.split("_").map { |part| part.empty? ? part : part[0].upcase + part[1..] }.join
191
+ end.join("::")
192
+ end
193
+
194
+ def collect_attribute_names(block_node)
195
+ return [] unless block_node.is_a?(Prism::BlockNode)
196
+
197
+ attributes = []
198
+ collect_attributes_from(block_node.body, attributes)
199
+ attributes
200
+ end
201
+
202
+ # Walks the block body collecting attribute names. The
203
+ # recogniser looks at top-level statements only —
204
+ # attributes inside `trait :admin do ... end` or other
205
+ # nested blocks are NOT collected in Phase 1 (a)
206
+ # (traits ship in a follow-up).
207
+ def collect_attributes_from(node, accumulator)
208
+ return unless node.is_a?(Prism::Node)
209
+
210
+ if node.is_a?(Prism::StatementsNode)
211
+ node.body.each { |stmt| record_attribute(stmt, accumulator) }
212
+ else
213
+ record_attribute(node, accumulator)
214
+ end
215
+ end
216
+
217
+ def record_attribute(node, accumulator)
218
+ return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
219
+ # Skip association / sequence / trait / framework
220
+ # methods — Phase 1 (a) only records plain attribute
221
+ # declarations.
222
+ return if SKIPPED_METHODS.include?(node.name)
223
+
224
+ name = if node.name == :add_attribute
225
+ literal_name_arg(node)
226
+ else
227
+ # method_missing form: the call's method
228
+ # name IS the attribute name.
229
+ node.name.to_s
230
+ end
231
+ accumulator << name if name
232
+ end
233
+
234
+ SKIPPED_METHODS = %i[
235
+ association sequence trait traits initialize_with
236
+ factory after before to_create skip_create
237
+ ].freeze
238
+ private_constant :SKIPPED_METHODS
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Factorybot < Rigor::Plugin::Base
6
+ # Per-run frozen index of discovered FactoryBot factories
7
+ # and the attribute keys each declares. Phase 1 (a) keys
8
+ # only the **literal symbol/string** factory name + the
9
+ # **literal symbol** attribute names; sequences,
10
+ # parent/child relationships, traits, and dynamically-
11
+ # named factories ship behind later slices.
12
+ #
13
+ # v0.2.0 (Pillar 2 Slice 3) adds `model_class` to each
14
+ # entry — the inferred or explicit class the factory
15
+ # builds. Resolved from:
16
+ #
17
+ # 1. An explicit `factory :user, class: User do`
18
+ # keyword option (ConstantReadNode / ConstantPathNode
19
+ # value).
20
+ # 2. An explicit `factory :user, class: "User"` keyword
21
+ # option (String value, supports `"Admin::User"`).
22
+ # 3. Fallback: inflected from the factory name —
23
+ # `:user` → `"User"`, `:admin_user` → `"AdminUser"`.
24
+ #
25
+ # The structure is intentionally flat: one entry per
26
+ # factory name. Attribute lists deduplicate.
27
+ class FactoryIndex
28
+ Entry = Data.define(:name, :attribute_names, :model_class)
29
+
30
+ attr_reader :entries
31
+
32
+ def initialize(entries)
33
+ @entries = entries.freeze
34
+ freeze
35
+ end
36
+
37
+ # @return [Entry, nil]
38
+ def find(factory_name)
39
+ @entries[factory_name.to_s]
40
+ end
41
+
42
+ def known?(factory_name)
43
+ @entries.key?(factory_name.to_s)
44
+ end
45
+
46
+ def names
47
+ @entries.keys
48
+ end
49
+
50
+ def empty?
51
+ @entries.empty?
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "factorybot/analyzer"
6
+ require_relative "factorybot/factory_discoverer"
7
+ require_relative "factorybot/factory_index"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-factorybot — validates `FactoryBot.create(:name,
12
+ # key: ...)` and the build / build_stubbed /
13
+ # attributes_for / *_list family against a per-run index
14
+ # built from `factory_search_paths`.
15
+ #
16
+ # **Phase 1 (a)** of the FactoryBot plugin family — the
17
+ # self-contained slice. Recognises factory NAMES + literal
18
+ # ATTRIBUTE KEYS in the call's keyword hash. Phase 1 (c)
19
+ # ships the AR column cross-check via the
20
+ # `rigor-activerecord` `:model_index` ADR-9 fact, after
21
+ # `rigor-activerecord` adds the matching publish hook.
22
+ # Traits, sequences, parent / child factories, and dynamic
23
+ # factory names are deferred to follow-up slices.
24
+ #
25
+ # ## Configuration
26
+ #
27
+ # plugins:
28
+ # - gem: rigor-factorybot
29
+ # config:
30
+ # factory_search_paths:
31
+ # - spec/factories
32
+ # - spec/factories.rb
33
+ # # Minitest projects override:
34
+ # # - test/factories
35
+ #
36
+ # ## What it checks
37
+ #
38
+ # - **Factory existence** — every entry call's first
39
+ # positional Symbol / String literal is looked up in
40
+ # the index. Missing factories emit `unknown-factory`
41
+ # with a `DidYouMean` suggestion.
42
+ # - **Attribute key existence** — every literal-Symbol
43
+ # keyword-argument key is matched against the factory's
44
+ # declared attribute names. Missing keys emit
45
+ # `unknown-attribute` with a `DidYouMean` suggestion.
46
+ # - **Trace** — recognised entry calls also emit a
47
+ # `factory-call` info diagnostic listing the factory's
48
+ # declared attribute set.
49
+ #
50
+ # ## Recognised entry methods
51
+ #
52
+ # `FactoryBot.create`, `.build`, `.build_stubbed`,
53
+ # `.attributes_for`, `.create_list`, `.build_list`,
54
+ # `.build_stubbed_list`. The legacy `FactoryGirl` constant
55
+ # is recognised identically. Implicit-receiver calls
56
+ # (`create(:name)` inside an `include FactoryBot::Syntax::Methods`
57
+ # context) are NOT recognised in Phase 1 (a) — too many
58
+ # false positives on plain `create` calls outside test
59
+ # files; this needs receiver-type inference (Phase 1 (b)).
60
+ #
61
+ # ## What's recognised inside `factory :name do ... end`
62
+ #
63
+ # - `name { "Alice" }` — implicit attribute via
64
+ # `method_missing` with a block (modern syntax).
65
+ # - `name "Alice"` — implicit attribute with a positional
66
+ # argument (legacy syntax).
67
+ # - `add_attribute(:name) { "Alice" }` — explicit form.
68
+ #
69
+ # Sequences (`sequence(:email) { ... }`), associations
70
+ # (`association :author`), traits (`trait :admin do ... end`),
71
+ # and parent / child relationships (`factory :admin,
72
+ # parent: :user do ... end`) are deferred to follow-up
73
+ # slices. Factories whose name is a non-literal expression
74
+ # (`factory FACTORY_NAME do ... end`) are silently skipped.
75
+ class Factorybot < Rigor::Plugin::Base
76
+ manifest(
77
+ id: "factorybot",
78
+ version: "0.2.0",
79
+ description: "Validates FactoryBot.create / build / attributes_for call shapes; " \
80
+ "publishes per-factory attribute set + inferred model class as the " \
81
+ ":factory_index ADR-9 fact (Pillar 2 Slice 3).",
82
+ config_schema: {
83
+ "factory_search_paths" => :array
84
+ },
85
+ consumes: [
86
+ { plugin_id: "activerecord", name: :model_index, optional: true }
87
+ ]
88
+ )
89
+
90
+ DEFAULT_FACTORY_SEARCH_PATHS = [
91
+ "spec/factories",
92
+ "spec/factories.rb"
93
+ ].freeze
94
+
95
+ producer :factory_index do |_params|
96
+ FactoryDiscoverer.new(
97
+ io_boundary: io_boundary,
98
+ search_paths: @factory_search_paths
99
+ ).discover
100
+ end
101
+
102
+ def init(services)
103
+ @services = services
104
+ @factory_search_paths = Array(
105
+ config.fetch("factory_search_paths", DEFAULT_FACTORY_SEARCH_PATHS)
106
+ ).map(&:to_s)
107
+ @factory_index = nil
108
+ @model_index = nil
109
+ @model_index_resolved = false
110
+ end
111
+
112
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
113
+ index = factory_index_or_nil
114
+ return [] if index.nil? || index.empty?
115
+
116
+ Analyzer.diagnose(
117
+ path: path, root: root,
118
+ factory_index: index, model_index: model_index_or_nil
119
+ ).map { |diag| build_diagnostic(diag) }
120
+ end
121
+
122
+ private
123
+
124
+ # Phase 1 (c) — lazily resolves the :model_index fact
125
+ # from rigor-activerecord. Returns nil when
126
+ # rigor-activerecord isn't loaded or hasn't published
127
+ # an index; the analyzer treats nil as "no cross-check"
128
+ # and falls back to Phase 1 (a) behaviour (factory
129
+ # attributes only).
130
+ def model_index_or_nil
131
+ return @model_index if @model_index_resolved
132
+
133
+ @model_index = @services.fact_store.read(plugin_id: "activerecord", name: :model_index)
134
+ @model_index_resolved = true
135
+ @model_index
136
+ end
137
+
138
+ def factory_index_or_nil
139
+ return @factory_index if @factory_index
140
+
141
+ prime_io_boundary_for_index
142
+ @factory_index = cache_for(:factory_index, params: {}).call
143
+ rescue StandardError
144
+ nil
145
+ end
146
+
147
+ def prime_io_boundary_for_index
148
+ @factory_search_paths.each do |root|
149
+ absolute = File.expand_path(root)
150
+ if File.file?(absolute)
151
+ safely_read(absolute)
152
+ elsif File.directory?(absolute)
153
+ Dir.glob(File.join(absolute, "**", "*.rb")).each { |p| safely_read(p) }
154
+ end
155
+ end
156
+ end
157
+
158
+ def safely_read(path)
159
+ io_boundary.read_file(path)
160
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
161
+ nil
162
+ end
163
+
164
+ def build_diagnostic(diag)
165
+ Rigor::Analysis::Diagnostic.new(
166
+ path: diag.path, line: diag.line, column: diag.column,
167
+ message: diag.message, severity: diag.severity, rule: diag.rule
168
+ )
169
+ end
170
+ end
171
+
172
+ Rigor::Plugin.register(Factorybot)
173
+ end
174
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/factorybot"