rigortype 0.1.10 → 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/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli.rb +16 -3
- 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
- metadata +149 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
class Activerecord < Rigor::Plugin::Base
|
|
6
|
+
# Tiny inflection helper for the common `ClassName → snake_case_plural`
|
|
7
|
+
# mapping Rails uses to derive table names. Deliberately
|
|
8
|
+
# narrow — handles the regular cases (`User → users`,
|
|
9
|
+
# `BlogPost → blog_posts`, `Category → categories`,
|
|
10
|
+
# `Bus → buses`, `Wolf → wolves`). Irregular plurals
|
|
11
|
+
# (`Person → people`, `Mouse → mice`, `Datum → data`) are
|
|
12
|
+
# NOT handled; the user is expected to declare
|
|
13
|
+
# `self.table_name = "people"` for those.
|
|
14
|
+
#
|
|
15
|
+
# Avoids an `activesupport` runtime dependency. Rails apps
|
|
16
|
+
# that need richer inflection should set explicit table
|
|
17
|
+
# names on the affected models.
|
|
18
|
+
module Inflector
|
|
19
|
+
IRREGULAR_PLURALS = {
|
|
20
|
+
# Common ones we still want to handle without bringing
|
|
21
|
+
# in a full inflection table. Users get the configured
|
|
22
|
+
# explicit table_name route for anything else.
|
|
23
|
+
"person" => "people",
|
|
24
|
+
"child" => "children",
|
|
25
|
+
"datum" => "data"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# `BlogPost` → `blog_posts`. `User::Profile` → `user_profiles`
|
|
31
|
+
# (Rails-style namespacing flattens with underscore).
|
|
32
|
+
def tableize(class_name)
|
|
33
|
+
underscore = underscore(class_name.to_s.gsub("::", "/"))
|
|
34
|
+
# `user/profiles` → `user_profiles`
|
|
35
|
+
underscore = underscore.tr("/", "_")
|
|
36
|
+
pluralize(underscore)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `BlogPost` → `blog_post`. Standard Rails-style underscore.
|
|
40
|
+
def underscore(camel_case_word)
|
|
41
|
+
word = camel_case_word.to_s.dup
|
|
42
|
+
word.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
43
|
+
word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
|
44
|
+
word.tr!("-", "_")
|
|
45
|
+
word.downcase!
|
|
46
|
+
word
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# `users` → `User`, `blog_posts` → `BlogPost`.
|
|
50
|
+
# Used by the association detector to map an
|
|
51
|
+
# association NAME (`has_many :posts` → `Post`) to the
|
|
52
|
+
# target class without requiring an explicit
|
|
53
|
+
# `class_name:` option. Singularises the word first,
|
|
54
|
+
# then camel-cases. Recognises the same irregular
|
|
55
|
+
# plurals as {.pluralize}.
|
|
56
|
+
def classify(word)
|
|
57
|
+
camelize(singularize(word.to_s))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `posts` → `post`, `categories` → `category`,
|
|
61
|
+
# `wolves` → `wolf`, `buses` → `bus`. The inverse of
|
|
62
|
+
# {.pluralize} for the regular cases this module
|
|
63
|
+
# recognises. Irregular forms (`people` → `person`)
|
|
64
|
+
# round-trip via {IRREGULAR_PLURALS}.
|
|
65
|
+
def singularize(word)
|
|
66
|
+
IRREGULAR_PLURALS.each { |singular, plural| return singular if word == plural }
|
|
67
|
+
|
|
68
|
+
case word
|
|
69
|
+
when /(.*[bcdfghjklmnpqrstvwxz])ies\z/
|
|
70
|
+
"#{Regexp.last_match(1)}y"
|
|
71
|
+
when /(.*[sxz]|.*[cs]h)es\z/
|
|
72
|
+
Regexp.last_match(0)[0..-3]
|
|
73
|
+
when /(.*)ves\z/
|
|
74
|
+
"#{Regexp.last_match(1)}f"
|
|
75
|
+
when /(.+)s\z/
|
|
76
|
+
Regexp.last_match(1)
|
|
77
|
+
else
|
|
78
|
+
word
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `blog_post` → `BlogPost`. Camelizes around `_` and
|
|
83
|
+
# `/` separators; the latter promotes namespace boundaries
|
|
84
|
+
# to `::` (Rails-style).
|
|
85
|
+
def camelize(snake)
|
|
86
|
+
snake.to_s.split("/").map do |segment|
|
|
87
|
+
segment.split("_").map { |part| part.empty? ? part : part[0].upcase + part[1..] }.join
|
|
88
|
+
end.join("::")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `user` → `users`, `category` → `categories`,
|
|
92
|
+
# `bus` → `buses`, `wolf` → `wolves`. Falls back to a
|
|
93
|
+
# plain `+ "s"` for unrecognised endings.
|
|
94
|
+
def pluralize(word)
|
|
95
|
+
return IRREGULAR_PLURALS[word] if IRREGULAR_PLURALS.key?(word)
|
|
96
|
+
|
|
97
|
+
case word
|
|
98
|
+
when /(.*[bcdfghjklmnpqrstvwxz])y\z/
|
|
99
|
+
# `category` → `categories`, `cherry` → `cherries`
|
|
100
|
+
"#{Regexp.last_match(1)}ies"
|
|
101
|
+
when /(.*[sxz]|.*[cs]h)\z/
|
|
102
|
+
# `bus` → `buses`, `box` → `boxes`, `dish` → `dishes`
|
|
103
|
+
"#{Regexp.last_match(0)}es"
|
|
104
|
+
when /(.*)fe?\z/
|
|
105
|
+
# `wolf` → `wolves`, `knife` → `knives`
|
|
106
|
+
"#{Regexp.last_match(1)}ves"
|
|
107
|
+
else
|
|
108
|
+
"#{word}s"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Activerecord < Rigor::Plugin::Base
|
|
8
|
+
# Walks the configured model search paths via the plugin's
|
|
9
|
+
# `IoBoundary`, parses each `.rb` file with Prism, and
|
|
10
|
+
# collects class declarations that resolve to ActiveRecord
|
|
11
|
+
# models.
|
|
12
|
+
#
|
|
13
|
+
# Discovery is a two-step pass. First every class declaration
|
|
14
|
+
# is captured as a *candidate* (its name, its superclass
|
|
15
|
+
# name, and its DSL metadata). Then a fixpoint marks a
|
|
16
|
+
# candidate as a model when its superclass is a configured
|
|
17
|
+
# base class OR (transitively) the class name of another
|
|
18
|
+
# model — this is what makes single-table-inheritance
|
|
19
|
+
# subclasses (`class Admin < User`) discoverable. Each STI
|
|
20
|
+
# child carries an `sti_parent:` pointer the {ModelIndex}
|
|
21
|
+
# uses to inherit the root model's table and DSL surface.
|
|
22
|
+
#
|
|
23
|
+
# Returns rows the {ModelIndex} consumes:
|
|
24
|
+
#
|
|
25
|
+
# { class_name: "User", table_name_override: nil, sti_parent: nil, ... }
|
|
26
|
+
# { class_name: "Admin", table_name_override: nil, sti_parent: "User", ... }
|
|
27
|
+
#
|
|
28
|
+
# Limitations (intentional for v0.1.0 of the plugin):
|
|
29
|
+
#
|
|
30
|
+
# - `self.table_name = "..."` recognised only when the RHS
|
|
31
|
+
# is a String literal. Computed names
|
|
32
|
+
# (`self.table_name = "#{tenant}_users"`) are skipped.
|
|
33
|
+
# - Modules (`class Admin::User < ApplicationRecord`) are
|
|
34
|
+
# recognised; the resulting class name is the lexical
|
|
35
|
+
# path (`Admin::User`).
|
|
36
|
+
# - The STI fixpoint matches a superclass name against model
|
|
37
|
+
# class names tolerating a leading `::`; richer constant
|
|
38
|
+
# resolution (relative namespacing) is not modelled.
|
|
39
|
+
class ModelDiscoverer
|
|
40
|
+
# @param io_boundary [Rigor::Plugin::IoBoundary]
|
|
41
|
+
# @param search_paths [Array<String>] absolute or
|
|
42
|
+
# project-relative paths.
|
|
43
|
+
# @param base_classes [Array<String>] superclass names that
|
|
44
|
+
# identify a class as an AR model.
|
|
45
|
+
def initialize(io_boundary:, search_paths:, base_classes:)
|
|
46
|
+
@io_boundary = io_boundary
|
|
47
|
+
@search_paths = search_paths
|
|
48
|
+
@base_classes = base_classes.to_set
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Array<Hash>] rows of { class_name:, table_name_override:, sti_parent:, ... }
|
|
52
|
+
def discover
|
|
53
|
+
candidates = []
|
|
54
|
+
ruby_files_under(@search_paths).each do |path|
|
|
55
|
+
contents = read_safely(path)
|
|
56
|
+
next if contents.nil?
|
|
57
|
+
|
|
58
|
+
tree = Prism.parse(contents).value
|
|
59
|
+
walk_for_classes(tree, []) { |candidate| candidates << candidate }
|
|
60
|
+
end
|
|
61
|
+
resolve_models(candidates)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Fixpoint over the captured class candidates: a candidate
|
|
67
|
+
# is a model when its superclass is a configured base
|
|
68
|
+
# class, or — transitively — the class name of an
|
|
69
|
+
# already-known model. The second arm is what discovers
|
|
70
|
+
# STI subclasses; the matched parent name is stamped onto
|
|
71
|
+
# the row as `sti_parent:` so the {ModelIndex} can inherit
|
|
72
|
+
# the root model's table and association surface.
|
|
73
|
+
#
|
|
74
|
+
# Non-model classes (POROs, service objects that happen to
|
|
75
|
+
# live under `app/models/`) never enter `model_names` and
|
|
76
|
+
# are dropped.
|
|
77
|
+
def resolve_models(candidates)
|
|
78
|
+
model_names = {}
|
|
79
|
+
sti_parent = {}
|
|
80
|
+
|
|
81
|
+
loop do
|
|
82
|
+
added = false
|
|
83
|
+
candidates.each do |candidate|
|
|
84
|
+
name = candidate[:class_name]
|
|
85
|
+
next if model_names.key?(name)
|
|
86
|
+
|
|
87
|
+
superclass = candidate[:superclass_name]
|
|
88
|
+
next if superclass.nil?
|
|
89
|
+
|
|
90
|
+
if @base_classes.include?(superclass)
|
|
91
|
+
model_names[name] = true
|
|
92
|
+
added = true
|
|
93
|
+
elsif (parent = model_match(superclass, model_names))
|
|
94
|
+
model_names[name] = true
|
|
95
|
+
sti_parent[name] = parent
|
|
96
|
+
added = true
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
break unless added
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
candidates.filter_map do |candidate|
|
|
103
|
+
name = candidate[:class_name]
|
|
104
|
+
next unless model_names.key?(name)
|
|
105
|
+
|
|
106
|
+
candidate.merge(sti_parent: sti_parent[name])
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Resolves a superclass NAME against the set of known
|
|
111
|
+
# model class names, tolerating a leading `::`. Returns
|
|
112
|
+
# the matched model class name, or nil.
|
|
113
|
+
def model_match(superclass_name, model_names)
|
|
114
|
+
return superclass_name if model_names.key?(superclass_name)
|
|
115
|
+
|
|
116
|
+
stripped = superclass_name.sub(/\A::/, "")
|
|
117
|
+
model_names.key?(stripped) ? stripped : nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def read_safely(path)
|
|
121
|
+
@io_boundary.read_file(path)
|
|
122
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def ruby_files_under(roots)
|
|
127
|
+
roots.flat_map do |root|
|
|
128
|
+
absolute = File.expand_path(root)
|
|
129
|
+
next [] unless File.directory?(absolute)
|
|
130
|
+
|
|
131
|
+
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def walk_for_classes(node, lexical_path, &)
|
|
136
|
+
return if node.nil?
|
|
137
|
+
|
|
138
|
+
case node
|
|
139
|
+
when Prism::ClassNode
|
|
140
|
+
visit_class(node, lexical_path, &)
|
|
141
|
+
when Prism::ModuleNode
|
|
142
|
+
visit_module(node, lexical_path, &)
|
|
143
|
+
else
|
|
144
|
+
node.compact_child_nodes.each { |child| walk_for_classes(child, lexical_path, &) }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Captures EVERY class declaration as a candidate — the
|
|
149
|
+
# `resolve_models` fixpoint decides afterwards which ones
|
|
150
|
+
# are models. The DSL metadata is extracted eagerly; for a
|
|
151
|
+
# non-model class it is simply discarded when the candidate
|
|
152
|
+
# is dropped.
|
|
153
|
+
def visit_class(node, lexical_path, &)
|
|
154
|
+
class_local_name = constant_path_name(node.constant_path)
|
|
155
|
+
return if class_local_name.nil?
|
|
156
|
+
|
|
157
|
+
full_name = (lexical_path + [class_local_name]).join("::")
|
|
158
|
+
superclass = constant_path_name(node.superclass) if node.superclass
|
|
159
|
+
|
|
160
|
+
yield({
|
|
161
|
+
class_name: full_name,
|
|
162
|
+
superclass_name: superclass,
|
|
163
|
+
table_name_override: lookup_table_name_override(node.body),
|
|
164
|
+
associations: lookup_associations(node.body),
|
|
165
|
+
enums: lookup_enums(node.body),
|
|
166
|
+
scopes: lookup_scopes(node.body),
|
|
167
|
+
validations: lookup_validations(node.body),
|
|
168
|
+
callbacks: lookup_callbacks(node.body),
|
|
169
|
+
aliases: lookup_aliases(node.body)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
# Recurse into the body in case nested classes exist.
|
|
173
|
+
inner_path = lexical_path + [class_local_name]
|
|
174
|
+
walk_for_classes(node.body, inner_path, &) if node.body
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def visit_module(node, lexical_path, &)
|
|
178
|
+
module_local_name = constant_path_name(node.constant_path)
|
|
179
|
+
return if module_local_name.nil?
|
|
180
|
+
|
|
181
|
+
inner_path = lexical_path + [module_local_name]
|
|
182
|
+
walk_for_classes(node.body, inner_path, &) if node.body
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Renders a constant-path node (`Admin::User`,
|
|
186
|
+
# `::ApplicationRecord`) as a String. Returns nil for
|
|
187
|
+
# shapes the discoverer chooses not to handle.
|
|
188
|
+
def constant_path_name(node)
|
|
189
|
+
return nil if node.nil?
|
|
190
|
+
|
|
191
|
+
case node
|
|
192
|
+
when Prism::ConstantReadNode
|
|
193
|
+
node.name.to_s
|
|
194
|
+
when Prism::ConstantPathNode
|
|
195
|
+
parts = []
|
|
196
|
+
current = node
|
|
197
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
198
|
+
parts.unshift(current.name.to_s)
|
|
199
|
+
current = current.parent
|
|
200
|
+
end
|
|
201
|
+
case current
|
|
202
|
+
when nil
|
|
203
|
+
"::#{parts.join('::')}"
|
|
204
|
+
when Prism::ConstantReadNode
|
|
205
|
+
"#{current.name}::#{parts.join('::')}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Looks for `self.table_name = "..."` at the top level of
|
|
211
|
+
# the class body. Returns the literal String when found,
|
|
212
|
+
# nil otherwise.
|
|
213
|
+
def lookup_table_name_override(body)
|
|
214
|
+
return nil if body.nil?
|
|
215
|
+
|
|
216
|
+
body.compact_child_nodes.each do |node|
|
|
217
|
+
next unless node.is_a?(Prism::CallNode) && node.name == :table_name=
|
|
218
|
+
next unless node.receiver.is_a?(Prism::SelfNode)
|
|
219
|
+
|
|
220
|
+
arg = node.arguments&.arguments&.first
|
|
221
|
+
return arg.unescaped if arg.is_a?(Prism::StringNode)
|
|
222
|
+
end
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Recognised single-instance and collection association
|
|
227
|
+
# DSL methods. The kind drives the eventual return-type
|
|
228
|
+
# contribution: singular associations narrow to
|
|
229
|
+
# `Nominal[Target] | nil`, plural ones currently degrade
|
|
230
|
+
# to the RBS envelope (relation types are a future track).
|
|
231
|
+
#
|
|
232
|
+
# `composed_of` value-object aggregations and
|
|
233
|
+
# `delegated_type` roles are folded in here too — both
|
|
234
|
+
# accept the association name as a `where` / `find_by`
|
|
235
|
+
# query key, so omitting them turns every such query into
|
|
236
|
+
# a false `unknown-column`. `composed_of` resolves to its
|
|
237
|
+
# value class (a real target); `delegated_type` is
|
|
238
|
+
# polymorphic (no single target).
|
|
239
|
+
ASSOCIATION_METHODS = {
|
|
240
|
+
belongs_to: :singular,
|
|
241
|
+
has_one: :singular,
|
|
242
|
+
has_many: :collection,
|
|
243
|
+
has_and_belongs_to_many: :collection,
|
|
244
|
+
composed_of: :singular,
|
|
245
|
+
delegated_type: :singular
|
|
246
|
+
}.freeze
|
|
247
|
+
private_constant :ASSOCIATION_METHODS
|
|
248
|
+
|
|
249
|
+
# Association DSL methods that are ALWAYS polymorphic —
|
|
250
|
+
# the accessor has no single static target class.
|
|
251
|
+
# `belongs_to` / `has_one` become polymorphic only with
|
|
252
|
+
# an explicit `polymorphic: true` option.
|
|
253
|
+
POLYMORPHIC_BY_DEFAULT = %i[delegated_type].freeze
|
|
254
|
+
private_constant :POLYMORPHIC_BY_DEFAULT
|
|
255
|
+
|
|
256
|
+
# Class-body declaration calls — the top-level `CallNode`s
|
|
257
|
+
# PLUS those nested inside a `with_options(...) do … end`
|
|
258
|
+
# block. `with_options` is Rails' idiom for sharing
|
|
259
|
+
# options across a group of `belongs_to` / `validates` /
|
|
260
|
+
# etc. declarations; without descending into it every
|
|
261
|
+
# association / enum / validation declared inside is
|
|
262
|
+
# invisible to the discoverer, turning `where(<assoc>:
|
|
263
|
+
# ...)` into a false `unknown-column`. Nested
|
|
264
|
+
# `with_options` blocks recurse.
|
|
265
|
+
#
|
|
266
|
+
# The options the `with_options` call itself carries (e.g.
|
|
267
|
+
# `with_options class_name: 'Account'`) are NOT merged into
|
|
268
|
+
# the nested calls — discovering the declaration name is
|
|
269
|
+
# what clears the false positive; the merged-option target
|
|
270
|
+
# precision is a separate refinement.
|
|
271
|
+
def declaration_calls(body)
|
|
272
|
+
return [] if body.nil?
|
|
273
|
+
|
|
274
|
+
body.compact_child_nodes.flat_map do |node|
|
|
275
|
+
next [] unless node.is_a?(Prism::CallNode)
|
|
276
|
+
|
|
277
|
+
if node.name == :with_options && node.block.is_a?(Prism::BlockNode)
|
|
278
|
+
declaration_calls(node.block.body)
|
|
279
|
+
else
|
|
280
|
+
[node]
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Walks the class body for association DSL calls and
|
|
286
|
+
# returns a list of rows shaped:
|
|
287
|
+
#
|
|
288
|
+
# { name: "user", kind: :singular, target: "User" }
|
|
289
|
+
#
|
|
290
|
+
# The `target` is resolved from an explicit
|
|
291
|
+
# `class_name: "Foo"` option when supplied, otherwise
|
|
292
|
+
# inferred from the association name via
|
|
293
|
+
# {Inflector.classify}. Calls whose first arg is not a
|
|
294
|
+
# Symbol literal (or whose `class_name:` is a non-literal
|
|
295
|
+
# expression) decline rather than guess.
|
|
296
|
+
def lookup_associations(body)
|
|
297
|
+
return [] if body.nil?
|
|
298
|
+
|
|
299
|
+
rows = []
|
|
300
|
+
declaration_calls(body).each do |node|
|
|
301
|
+
kind = ASSOCIATION_METHODS[node.name]
|
|
302
|
+
next if kind.nil?
|
|
303
|
+
next if node.receiver # skip `self.has_many` and similar
|
|
304
|
+
|
|
305
|
+
row = build_association_row(node, kind)
|
|
306
|
+
rows << row unless row.nil?
|
|
307
|
+
end
|
|
308
|
+
rows
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def build_association_row(node, kind)
|
|
312
|
+
args = node.arguments&.arguments
|
|
313
|
+
return nil if args.nil? || args.empty?
|
|
314
|
+
|
|
315
|
+
name_node = args.first
|
|
316
|
+
return nil unless name_node.is_a?(Prism::SymbolNode)
|
|
317
|
+
|
|
318
|
+
name = name_node.unescaped
|
|
319
|
+
polymorphic = POLYMORPHIC_BY_DEFAULT.include?(node.name) ||
|
|
320
|
+
association_option(args, "polymorphic") == true
|
|
321
|
+
|
|
322
|
+
# A polymorphic association has no single static target
|
|
323
|
+
# class — `target` is nil and the flow contribution
|
|
324
|
+
# declines to narrow rather than inventing a wrong
|
|
325
|
+
# `Nominal[<classified-name>]`.
|
|
326
|
+
if polymorphic
|
|
327
|
+
target = nil
|
|
328
|
+
else
|
|
329
|
+
target = explicit_class_name(args) || Inflector.classify(name)
|
|
330
|
+
return nil if target.nil? || target.empty?
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
{ name: name, kind: kind, target: target, polymorphic: polymorphic,
|
|
334
|
+
nullable: association_nullable?(node.name, args) }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Whether a `:singular` association's accessor can return
|
|
338
|
+
# `nil`. `has_one` genuinely can (no associated record →
|
|
339
|
+
# `nil`). `belongs_to` is **required (non-`nil`) by default
|
|
340
|
+
# since Rails 5** (`belongs_to_required_by_default`); it
|
|
341
|
+
# becomes nullable only when the call passes `optional: true`
|
|
342
|
+
# or `required: false`. `composed_of` is non-nullable
|
|
343
|
+
# unless `allow_nil: true`. `delegated_type` roles are
|
|
344
|
+
# required. A non-literal option value declines to the
|
|
345
|
+
# default rather than guessing.
|
|
346
|
+
def association_nullable?(method_name, args)
|
|
347
|
+
case method_name
|
|
348
|
+
when :has_one
|
|
349
|
+
true
|
|
350
|
+
when :belongs_to
|
|
351
|
+
association_option(args, "optional") == true ||
|
|
352
|
+
association_option(args, "required") == false
|
|
353
|
+
when :composed_of
|
|
354
|
+
association_option(args, "allow_nil") == true
|
|
355
|
+
else
|
|
356
|
+
false
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Reads a literal boolean association option (`optional:` /
|
|
361
|
+
# `required:`). Returns `true` / `false` for a literal, or
|
|
362
|
+
# `nil` when the key is absent or its value is non-literal.
|
|
363
|
+
def association_option(args, key)
|
|
364
|
+
args.each do |arg|
|
|
365
|
+
next unless arg.is_a?(Prism::KeywordHashNode)
|
|
366
|
+
|
|
367
|
+
arg.elements.each do |pair|
|
|
368
|
+
next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
|
|
369
|
+
next unless pair.key.unescaped == key
|
|
370
|
+
|
|
371
|
+
return true if pair.value.is_a?(Prism::TrueNode)
|
|
372
|
+
return false if pair.value.is_a?(Prism::FalseNode)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
nil
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def explicit_class_name(args)
|
|
379
|
+
args.each do |arg|
|
|
380
|
+
next unless arg.is_a?(Prism::KeywordHashNode)
|
|
381
|
+
|
|
382
|
+
arg.elements.each do |pair|
|
|
383
|
+
next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
|
|
384
|
+
next unless pair.key.unescaped == "class_name"
|
|
385
|
+
next unless pair.value.is_a?(Prism::StringNode)
|
|
386
|
+
|
|
387
|
+
return pair.value.unescaped
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
nil
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# `enum status: { active: 0, archived: 1 }` (Rails ≤6)
|
|
394
|
+
# and `enum :status, [:active, :archived]` (Rails 7+).
|
|
395
|
+
# Returns `Hash<column_name => Array<Symbol>>`.
|
|
396
|
+
# Non-literal forms decline rather than guess.
|
|
397
|
+
def lookup_enums(body)
|
|
398
|
+
return {} if body.nil?
|
|
399
|
+
|
|
400
|
+
enums = {}
|
|
401
|
+
declaration_calls(body).each do |node|
|
|
402
|
+
next unless node.name == :enum
|
|
403
|
+
next if node.receiver
|
|
404
|
+
|
|
405
|
+
row = parse_enum_call(node)
|
|
406
|
+
next if row.nil?
|
|
407
|
+
|
|
408
|
+
enums[row[:column]] = row[:values]
|
|
409
|
+
end
|
|
410
|
+
enums.freeze
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def parse_enum_call(node)
|
|
414
|
+
args = node.arguments&.arguments
|
|
415
|
+
return nil if args.nil? || args.empty?
|
|
416
|
+
|
|
417
|
+
first = args.first
|
|
418
|
+
if first.is_a?(Prism::SymbolNode) && args.size >= 2
|
|
419
|
+
values = enum_values_from(args[1])
|
|
420
|
+
return nil if values.nil?
|
|
421
|
+
|
|
422
|
+
{ column: first.unescaped, values: values }
|
|
423
|
+
elsif first.is_a?(Prism::KeywordHashNode)
|
|
424
|
+
entry = first.elements.find { |e| e.is_a?(Prism::AssocNode) && e.key.is_a?(Prism::SymbolNode) }
|
|
425
|
+
return nil if entry.nil?
|
|
426
|
+
|
|
427
|
+
values = enum_values_from(entry.value)
|
|
428
|
+
return nil if values.nil?
|
|
429
|
+
|
|
430
|
+
{ column: entry.key.unescaped, values: values }
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def enum_values_from(node)
|
|
435
|
+
case node
|
|
436
|
+
when Prism::ArrayNode
|
|
437
|
+
symbols = node.elements.filter_map { |e| e.unescaped if e.is_a?(Prism::SymbolNode) }
|
|
438
|
+
return nil if symbols.size != node.elements.size
|
|
439
|
+
|
|
440
|
+
symbols
|
|
441
|
+
when Prism::HashNode
|
|
442
|
+
node.elements.filter_map do |e|
|
|
443
|
+
next nil unless e.is_a?(Prism::AssocNode) && e.key.is_a?(Prism::SymbolNode)
|
|
444
|
+
|
|
445
|
+
e.key.unescaped
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# `scope :active, -> { ... }`. Records the scope name
|
|
451
|
+
# only (the body is intentionally NOT introspected —
|
|
452
|
+
# scopes return ActiveRecord::Relation, which Rigor
|
|
453
|
+
# doesn't carry a precise type for yet).
|
|
454
|
+
def lookup_scopes(body)
|
|
455
|
+
return [] if body.nil?
|
|
456
|
+
|
|
457
|
+
scopes = []
|
|
458
|
+
declaration_calls(body).each do |node|
|
|
459
|
+
next unless node.name == :scope
|
|
460
|
+
next if node.receiver
|
|
461
|
+
|
|
462
|
+
args = node.arguments&.arguments
|
|
463
|
+
next if args.nil? || args.empty?
|
|
464
|
+
|
|
465
|
+
name_node = args.first
|
|
466
|
+
next unless name_node.is_a?(Prism::SymbolNode)
|
|
467
|
+
|
|
468
|
+
scopes << name_node.unescaped
|
|
469
|
+
end
|
|
470
|
+
scopes.freeze
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# `validates :name, presence: true, length: { maximum: 100 }`.
|
|
474
|
+
# Records the attribute name (the validator option set
|
|
475
|
+
# is ignored — the value here is the diagnostic
|
|
476
|
+
# `validates :unknown_attr` surfacing when the attribute
|
|
477
|
+
# isn't a column on the table).
|
|
478
|
+
def lookup_validations(body)
|
|
479
|
+
return [] if body.nil?
|
|
480
|
+
|
|
481
|
+
attrs = []
|
|
482
|
+
declaration_calls(body).each do |node|
|
|
483
|
+
next unless %i[validates validates_presence_of validates_length_of
|
|
484
|
+
validates_format_of validates_uniqueness_of].include?(node.name)
|
|
485
|
+
next if node.receiver
|
|
486
|
+
|
|
487
|
+
attrs.concat(symbol_args(node))
|
|
488
|
+
end
|
|
489
|
+
attrs.uniq.freeze
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# `before_save :foo`, `after_create :bar`, etc. Records
|
|
493
|
+
# the referenced method name (a Symbol literal). The
|
|
494
|
+
# diagnostic value is "did you forget to `def` this?".
|
|
495
|
+
# Block callbacks (`before_save { ... }`) decline.
|
|
496
|
+
CALLBACK_METHODS = %i[
|
|
497
|
+
before_validation after_validation
|
|
498
|
+
before_save after_save around_save
|
|
499
|
+
before_create after_create around_create
|
|
500
|
+
before_update after_update around_update
|
|
501
|
+
before_destroy after_destroy around_destroy
|
|
502
|
+
after_commit after_rollback
|
|
503
|
+
after_initialize after_find
|
|
504
|
+
].freeze
|
|
505
|
+
private_constant :CALLBACK_METHODS
|
|
506
|
+
|
|
507
|
+
def lookup_callbacks(body)
|
|
508
|
+
return [] if body.nil?
|
|
509
|
+
|
|
510
|
+
targets = []
|
|
511
|
+
declaration_calls(body).each do |node|
|
|
512
|
+
next unless CALLBACK_METHODS.include?(node.name)
|
|
513
|
+
next if node.receiver
|
|
514
|
+
|
|
515
|
+
symbol_args(node).each do |name|
|
|
516
|
+
targets << { name: name, callback: node.name.to_s }
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
targets.freeze
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# `alias_attribute :new_name, :old_name`. Records the
|
|
523
|
+
# mapping so the analyzer accepts the alias as a query
|
|
524
|
+
# key — without it every `where(<alias>: ...)` /
|
|
525
|
+
# `find_by(<alias>: ...)` call surfaces as a false
|
|
526
|
+
# `unknown-column`. Returns `Hash<alias => target>`;
|
|
527
|
+
# non-Symbol-literal forms decline rather than guess.
|
|
528
|
+
def lookup_aliases(body)
|
|
529
|
+
return {} if body.nil?
|
|
530
|
+
|
|
531
|
+
aliases = {}
|
|
532
|
+
declaration_calls(body).each do |node|
|
|
533
|
+
next unless node.name == :alias_attribute
|
|
534
|
+
next if node.receiver
|
|
535
|
+
|
|
536
|
+
args = node.arguments&.arguments
|
|
537
|
+
next if args.nil? || args.size < 2
|
|
538
|
+
|
|
539
|
+
new_name = args[0]
|
|
540
|
+
old_name = args[1]
|
|
541
|
+
next unless new_name.is_a?(Prism::SymbolNode) && old_name.is_a?(Prism::SymbolNode)
|
|
542
|
+
|
|
543
|
+
aliases[new_name.unescaped] = old_name.unescaped
|
|
544
|
+
end
|
|
545
|
+
aliases.freeze
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Collects every Symbol-literal positional argument
|
|
549
|
+
# from a CallNode. Used by both `lookup_validations`
|
|
550
|
+
# and `lookup_callbacks` to extract the attribute /
|
|
551
|
+
# method name list.
|
|
552
|
+
def symbol_args(node)
|
|
553
|
+
args = node.arguments&.arguments
|
|
554
|
+
return [] if args.nil?
|
|
555
|
+
|
|
556
|
+
args.filter_map { |arg| arg.unescaped if arg.is_a?(Prism::SymbolNode) }
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
end
|