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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +88 -5
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- 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
|