rigortype 0.2.8 → 0.2.9
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/data/core_overlay/csv.rbs +28 -0
- data/data/core_overlay/psych.rbs +22 -0
- data/docs/handbook/01-getting-started.md +9 -1
- data/docs/handbook/02-everyday-types.md +4 -1
- data/docs/handbook/08-understanding-errors.md +3 -3
- data/docs/manual/01-installation.md +1 -0
- data/docs/manual/02-cli-reference.md +16 -7
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/14-rails-quickstart.md +4 -2
- data/docs/manual/15-type-protection-coverage.md +21 -0
- data/docs/manual/plugins/rigor-actionpack.md +1 -1
- data/docs/manual/plugins/rigor-activerecord.md +12 -5
- data/docs/manual/plugins/rigor-rails-routes.md +11 -0
- data/lib/rigor/cache/store.rb +174 -57
- data/lib/rigor/cli/coverage_command.rb +50 -29
- data/lib/rigor/cli/diagnostic_formats.rb +4 -1
- data/lib/rigor/cli/protection_fork_scan.rb +55 -0
- data/lib/rigor/cli/protection_report.rb +7 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
- data/lib/rigor/environment.rb +54 -18
- data/lib/rigor/inference/expression_typer.rb +20 -2
- data/lib/rigor/inference/fork_map.rb +87 -0
- data/lib/rigor/inference/narrowing.rb +118 -0
- data/lib/rigor/inference/parameter_inference_collector.rb +55 -10
- data/lib/rigor/inference/scope_indexer.rb +9 -13
- data/lib/rigor/inference/statement_evaluator.rb +48 -3
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +61 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +189 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +41 -10
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
- data/sig/rigor/environment.rbs +1 -0
- metadata +8 -1
|
@@ -62,6 +62,9 @@ class Object
|
|
|
62
62
|
# `acts_like?(:string)` / `acts_like?(:date)` / `acts_like?(:time)`
|
|
63
63
|
# is ActiveSupport's "duck-typing helper" predicate.
|
|
64
64
|
def acts_like?: (Symbol | String) -> bool
|
|
65
|
+
|
|
66
|
+
# `core_ext/object/inclusion` — `x.in?([a, b])` / `x.in?(1..10)`.
|
|
67
|
+
def in?: (untyped) -> bool
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
# `nil.blank?` / `nil.present?` / `nil.try` are the most frequent
|
|
@@ -99,6 +102,9 @@ class String
|
|
|
99
102
|
def demodulize: () -> String
|
|
100
103
|
def deconstantize: () -> String
|
|
101
104
|
def titleize: () -> String
|
|
105
|
+
def titlecase: () -> String
|
|
106
|
+
def dasherize: () -> String
|
|
107
|
+
def upcase_first: () -> String
|
|
102
108
|
def parameterize: (?separator: String, ?preserve_case: bool, ?locale: Symbol?) -> String
|
|
103
109
|
def tableize: () -> String
|
|
104
110
|
def foreign_key: (?bool separate_class_name_and_id_with_underscore) -> String
|
|
@@ -112,6 +118,9 @@ class String
|
|
|
112
118
|
def truncate: (Integer truncate_at, ?omission: String, ?separator: String | Regexp | nil) -> String
|
|
113
119
|
def truncate_bytes: (Integer truncate_at, ?omission: String) -> String
|
|
114
120
|
def truncate_words: (Integer words_count, ?omission: String, ?separator: String | Regexp | nil) -> String
|
|
121
|
+
# `remove` / `remove!` delete every occurrence of the given patterns.
|
|
122
|
+
def remove: (*Regexp | String patterns) -> String
|
|
123
|
+
def remove!: (*Regexp | String patterns) -> String
|
|
115
124
|
|
|
116
125
|
# `core_ext/string/output_safety` — `html_safe` returns an
|
|
117
126
|
# `ActiveSupport::SafeBuffer` (a String subclass). The closest
|
|
@@ -274,6 +283,9 @@ class Time
|
|
|
274
283
|
def noon: () -> Time
|
|
275
284
|
def utc?: () -> bool
|
|
276
285
|
def acts_like_time?: () -> true
|
|
286
|
+
# `advance(days: 1, months: -2)` and `all_day` (a `beginning_of_day..end_of_day` Range).
|
|
287
|
+
def advance: (untyped options) -> Time
|
|
288
|
+
def all_day: () -> Range[Time]
|
|
277
289
|
end
|
|
278
290
|
|
|
279
291
|
# ---------------------------------------------------------------
|
|
@@ -314,6 +326,11 @@ class Date
|
|
|
314
326
|
def at_beginning_of_day: () -> Time
|
|
315
327
|
def end_of_day: () -> Time
|
|
316
328
|
def at_end_of_day: () -> Time
|
|
329
|
+
# `advance(days: 1)`, `all_day` (a Time Range), and `to_time(form)` — the
|
|
330
|
+
# `:local` / `:utc` form arg core RBS' `Date#to_time` (arity 0) omits.
|
|
331
|
+
def advance: (untyped options) -> Date
|
|
332
|
+
def all_day: () -> Range[Time]
|
|
333
|
+
def to_time: (?Symbol form) -> Time
|
|
317
334
|
end
|
|
318
335
|
|
|
319
336
|
# ---------------------------------------------------------------
|
|
@@ -476,3 +493,18 @@ end
|
|
|
476
493
|
class String
|
|
477
494
|
def exclude?: (String) -> bool
|
|
478
495
|
end
|
|
496
|
+
|
|
497
|
+
# ---------------------------------------------------------------
|
|
498
|
+
# ERB::Util — ActionView extends it with `html_escape_once`
|
|
499
|
+
# ---------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
# `ERB` is a CLASS in Ruby / RBS (not a module), so reopen it as a class —
|
|
502
|
+
# a `module ERB` wrapper collides with upstream `class ERB` once the `erb`
|
|
503
|
+
# stdlib is in scope. `ERB::Util` itself is a module.
|
|
504
|
+
class ERB
|
|
505
|
+
module Util
|
|
506
|
+
# `ERB::Util.html_escape_once(s)` escapes HTML without double-escaping
|
|
507
|
+
# an already-escaped entity. ActionView adds it on top of stdlib ERB.
|
|
508
|
+
def self.html_escape_once: (untyped) -> String
|
|
509
|
+
end
|
|
510
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class RailsRoutes < Rigor::Plugin::Base
|
|
8
|
+
# Recognises the helper *namespaces* the `grape-path-helpers` gem generates, so a call to one of them
|
|
9
|
+
# does not false-fire `unknown-helper`.
|
|
10
|
+
#
|
|
11
|
+
# The gem names each helper after its route's path segments, joined with `_`
|
|
12
|
+
# (`DecoratedRoute#path_helper_name`): `/api/:version/groups/:id/badges` under `version 'v4'` becomes
|
|
13
|
+
# `api_v4_groups_badges_path`. It walks `Grape::API::Instance.routes` — the *runtime* route table — and
|
|
14
|
+
# real grape sources build that table with metaprogramming (GitLab's `%w[group project].each { |t|
|
|
15
|
+
# resource t.pluralize … }`). A static parser cannot enumerate those names, and deriving only the
|
|
16
|
+
# easy ones would be worse than deriving none: every route it missed would keep firing on working
|
|
17
|
+
# code.
|
|
18
|
+
#
|
|
19
|
+
# What IS static is the leading segments. `prefix :api` contributes the first; `version 'v4'`
|
|
20
|
+
# contributes the second when the strategy is `:path` (Grape's default — `using: :header` / `:param`
|
|
21
|
+
# keeps the version out of the URL, and so out of the helper name). Both are literal arguments in the
|
|
22
|
+
# body of a class whose superclass chain reaches `Grape::API`. Everything after them is opaque.
|
|
23
|
+
#
|
|
24
|
+
# So the namespace `api_v4_…_path` is treated as open — a name the project's routes may define but
|
|
25
|
+
# Rigor cannot enumerate, so proving it undefined is unsound (the ADR-26 `open_receivers` reasoning,
|
|
26
|
+
# already applied in this plugin to Devise's runtime-provider OmniAuth family). Teeth survive where the
|
|
27
|
+
# gem's own contract keeps them sound: it defines no `_url` helper
|
|
28
|
+
# (`NamedRouteMatcher#method_missing` returns `super` unless the name ends `_path`), so
|
|
29
|
+
# `api_v4_anything_url` still fires, as does any name outside a declared prefix.
|
|
30
|
+
#
|
|
31
|
+
# Design note: `docs/notes/20260710-grape-path-helper-namespace.md`.
|
|
32
|
+
module GrapeApiDiscoverer
|
|
33
|
+
# A class is a grape API when its superclass chain reaches one of these. `Grape::API::Instance` is
|
|
34
|
+
# what a mounted `Grape::API` subclass actually becomes, and real code inherits from it directly
|
|
35
|
+
# (GitLab's `API::Base < Grape::API::Instance`).
|
|
36
|
+
GRAPE_BASE_NAMES = ["Grape::API", "Grape::API::Instance"].freeze
|
|
37
|
+
|
|
38
|
+
# `version 'v4', using: :path` puts the version in the URL. `:header` / `:param` do not, so those
|
|
39
|
+
# versions contribute no helper-name segment. Grape's default strategy is `:path`.
|
|
40
|
+
PATH_VERSION_STRATEGY = :path
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# @param contents_per_path [Hash{String => String}] file path → source text, read by the caller
|
|
45
|
+
# (through the trusted `IoBoundary`, so cache invalidation works).
|
|
46
|
+
# @return [Array<String>] the recognised helper-name prefixes (`["api_v3", "api_v4"]`). Empty when
|
|
47
|
+
# the project declares no grape API — nothing changes for such a project.
|
|
48
|
+
def discover(contents_per_path)
|
|
49
|
+
declarations = {}
|
|
50
|
+
superclasses = {}
|
|
51
|
+
contents_per_path.each_value do |contents|
|
|
52
|
+
collect_from_contents(contents, declarations, superclasses)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
# Best-effort: a parse failure in one grape file must not abort discovery for the rest. The core
|
|
55
|
+
# pipeline surfaces the parse error separately.
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
prefixes_for(declarations, superclasses)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Composes `prefix` × `version` into helper-name prefixes, for grape classes only. A class with a
|
|
62
|
+
# prefix and no path-strategy version contributes the bare prefix; one with versions and no prefix
|
|
63
|
+
# contributes each version alone.
|
|
64
|
+
def prefixes_for(declarations, superclasses)
|
|
65
|
+
declarations.filter_map do |class_name, declaration|
|
|
66
|
+
next unless grape_api?(class_name, superclasses)
|
|
67
|
+
|
|
68
|
+
segments = declaration[:prefix]
|
|
69
|
+
versions = declaration[:versions]
|
|
70
|
+
next segments.join("_") if versions.empty? && !segments.empty?
|
|
71
|
+
|
|
72
|
+
versions.map { |version| (segments + [version]).join("_") }
|
|
73
|
+
end.flatten.reject(&:empty?).uniq
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Follows the recorded superclass chain (cycle-guarded) looking for a grape base. The chain lives
|
|
77
|
+
# entirely inside the scanned files, so an unresolvable parent simply ends the walk.
|
|
78
|
+
def grape_api?(class_name, superclasses)
|
|
79
|
+
seen = {}
|
|
80
|
+
current = class_name
|
|
81
|
+
while current && !seen[current]
|
|
82
|
+
seen[current] = true
|
|
83
|
+
parent = superclasses[current]
|
|
84
|
+
return true if parent && GRAPE_BASE_NAMES.include?(parent)
|
|
85
|
+
|
|
86
|
+
current = parent
|
|
87
|
+
end
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def collect_from_contents(contents, declarations, superclasses)
|
|
92
|
+
root = Prism.parse(contents).value
|
|
93
|
+
walk(root, [], declarations, superclasses)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def walk(node, prefix_path, declarations, superclasses)
|
|
97
|
+
return unless node.is_a?(Prism::Node)
|
|
98
|
+
|
|
99
|
+
if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
100
|
+
name = constant_name(node.constant_path)
|
|
101
|
+
if name
|
|
102
|
+
qualified = (prefix_path + [name]).join("::")
|
|
103
|
+
record_class(node, qualified, declarations, superclasses) if node.is_a?(Prism::ClassNode)
|
|
104
|
+
walk(node.body, prefix_path + [name], declarations, superclasses) if node.body
|
|
105
|
+
return
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
node.compact_child_nodes.each { |child| walk(child, prefix_path, declarations, superclasses) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def record_class(node, qualified, declarations, superclasses)
|
|
113
|
+
parent = constant_name(node.superclass)
|
|
114
|
+
superclasses[qualified] = parent if parent
|
|
115
|
+
declaration = { prefix: [], versions: [] }
|
|
116
|
+
statements_of(node.body).each { |statement| absorb_declaration(statement, declaration) }
|
|
117
|
+
declarations[qualified] = declaration unless declaration[:prefix].empty? && declaration[:versions].empty?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# `prefix :api` / `prefix 'api/v2'` (a multi-segment prefix splits on `/`), and `version 'v4'` /
|
|
121
|
+
# `version 'v4', using: :path` — both in block and non-block form.
|
|
122
|
+
def absorb_declaration(node, declaration)
|
|
123
|
+
return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
124
|
+
|
|
125
|
+
case node.name
|
|
126
|
+
when :prefix
|
|
127
|
+
value = literal_value(first_argument(node))
|
|
128
|
+
declaration[:prefix] = value.split("/").reject(&:empty?) if value
|
|
129
|
+
when :version
|
|
130
|
+
value = literal_value(first_argument(node))
|
|
131
|
+
declaration[:versions] << value if value && path_strategy?(node)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# True unless an explicit `using:` names a non-path strategy. Grape defaults to `:path`.
|
|
136
|
+
def path_strategy?(node)
|
|
137
|
+
arguments = node.arguments&.arguments || []
|
|
138
|
+
keywords = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
|
|
139
|
+
return true if keywords.nil?
|
|
140
|
+
|
|
141
|
+
using = keywords.elements.find do |element|
|
|
142
|
+
element.is_a?(Prism::AssocNode) && literal_value(element.key) == "using"
|
|
143
|
+
end
|
|
144
|
+
return true if using.nil?
|
|
145
|
+
|
|
146
|
+
literal_value(using.value) == PATH_VERSION_STRATEGY.to_s
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def first_argument(node)
|
|
150
|
+
node.arguments&.arguments&.first
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Symbol and String literals only. A computed prefix / version is not a declaration we can ground a
|
|
154
|
+
# namespace in, so it contributes nothing.
|
|
155
|
+
def literal_value(node)
|
|
156
|
+
case node
|
|
157
|
+
when Prism::SymbolNode, Prism::StringNode then node.unescaped
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def constant_name(node)
|
|
162
|
+
case node
|
|
163
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
164
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def constant_path_name(node)
|
|
169
|
+
parent = node.parent
|
|
170
|
+
own = node.name&.to_s
|
|
171
|
+
return nil unless own
|
|
172
|
+
return own if parent.nil? # `::Foo` — a cbase root
|
|
173
|
+
|
|
174
|
+
parent_name = constant_name(parent)
|
|
175
|
+
parent_name ? "#{parent_name}::#{own}" : own
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def statements_of(body)
|
|
179
|
+
case body
|
|
180
|
+
when Prism::StatementsNode then body.body
|
|
181
|
+
when Prism::BeginNode then statements_of(body.statements)
|
|
182
|
+
when nil then []
|
|
183
|
+
else [body]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -36,7 +36,11 @@ module Rigor
|
|
|
36
36
|
# (`<resource>_<provider>_omniauth_(authorize|callback)_(path|url)`) whose provider segment is
|
|
37
37
|
# supplied at runtime — no per-name entry is registered for them but they MUST NOT fire
|
|
38
38
|
# `unknown-helper` either.
|
|
39
|
-
|
|
39
|
+
# @param grape_prefixes [Enumerable<String>] helper-name prefixes generated by `grape-path-helpers`
|
|
40
|
+
# (`"api_v4"`), composed by {GrapeApiDiscoverer} from the project's own `prefix` / `version`
|
|
41
|
+
# declarations. The names beyond the prefix come from grape's runtime route table and cannot be
|
|
42
|
+
# enumerated statically, so the namespace is open — same contract as the OmniAuth family above.
|
|
43
|
+
def initialize(entries, custom_helpers: [], devise_resources: [], grape_prefixes: [])
|
|
40
44
|
@entries = entries.freeze
|
|
41
45
|
# Multimap: a single helper name can map to multiple entries when an uncountable-noun resource
|
|
42
46
|
# registers both an arity-0 index helper and an arity-1 show helper under the same `news_path`
|
|
@@ -45,6 +49,7 @@ module Rigor
|
|
|
45
49
|
@by_name = entries.group_by(&:name).transform_values(&:freeze).freeze
|
|
46
50
|
@custom_helpers = custom_helpers.to_set.freeze
|
|
47
51
|
@devise_resources = devise_resources.to_set(&:to_s).freeze
|
|
52
|
+
@grape_prefixes = grape_prefixes.to_set(&:to_s).freeze
|
|
48
53
|
freeze
|
|
49
54
|
end
|
|
50
55
|
|
|
@@ -67,10 +72,23 @@ module Rigor
|
|
|
67
72
|
name = helper_name.to_s
|
|
68
73
|
return true if @by_name.key?(name)
|
|
69
74
|
return true if @custom_helpers.include?(name)
|
|
75
|
+
return true if grape_match?(name)
|
|
70
76
|
|
|
71
77
|
omniauth_match?(name)
|
|
72
78
|
end
|
|
73
79
|
|
|
80
|
+
# `<prefix>_<anything>_path` for a prefix the project's grape API declares. Only the `_path` suffix
|
|
81
|
+
# matches: `grape-path-helpers` defines no `_url` helper (its `method_missing` returns `super` unless
|
|
82
|
+
# the name ends with `_path`), so `api_v4_groups_url` is a genuine unknown helper and still fires.
|
|
83
|
+
# The bare `<prefix>_path` (the namespace root) matches too.
|
|
84
|
+
def grape_match?(name)
|
|
85
|
+
return false if @grape_prefixes.empty?
|
|
86
|
+
return false unless name.end_with?("_path")
|
|
87
|
+
|
|
88
|
+
stem = name.delete_suffix("_path")
|
|
89
|
+
@grape_prefixes.any? { |prefix| stem == prefix || stem.start_with?("#{prefix}_") }
|
|
90
|
+
end
|
|
91
|
+
|
|
74
92
|
# `<resource>_<provider>_omniauth_(authorize|callback)_(path|url)` — when `<resource>` matches a
|
|
75
93
|
# declared `devise_for` resource the helper is dynamic-provider Devise OmniAuth. The provider
|
|
76
94
|
# segment is opaque to a static parser, so we accept any non-empty token between the resource and
|
|
@@ -56,9 +56,11 @@ module Rigor
|
|
|
56
56
|
# draw partial from `config/routes/name.rb`. Returns file contents
|
|
57
57
|
# or nil when the file is absent.
|
|
58
58
|
# @return [HelperTable]
|
|
59
|
-
def parse(contents, file_reader: nil, custom_helpers: [])
|
|
59
|
+
def parse(contents, file_reader: nil, custom_helpers: [], grape_prefixes: [])
|
|
60
60
|
parse_result = Prism.parse(contents)
|
|
61
|
-
|
|
61
|
+
unless parse_result.errors.empty?
|
|
62
|
+
return HelperTable.new([], custom_helpers: custom_helpers, grape_prefixes: grape_prefixes)
|
|
63
|
+
end
|
|
62
64
|
|
|
63
65
|
context = Context.new(file_reader: file_reader)
|
|
64
66
|
interpret(parse_result.value, context)
|
|
@@ -80,7 +82,8 @@ module Rigor
|
|
|
80
82
|
)
|
|
81
83
|
]
|
|
82
84
|
end
|
|
83
|
-
HelperTable.new(paired, custom_helpers: custom_helpers, devise_resources: context.devise_resources
|
|
85
|
+
HelperTable.new(paired, custom_helpers: custom_helpers, devise_resources: context.devise_resources,
|
|
86
|
+
grape_prefixes: grape_prefixes)
|
|
84
87
|
end
|
|
85
88
|
|
|
86
89
|
# For every registered alias rule `(from_str, to_str, arity_delta)`, find every existing entry whose
|
|
@@ -808,6 +811,12 @@ module Rigor
|
|
|
808
811
|
# String key in the trailing keyword/options hash (its value is the `controller#action`). Without
|
|
809
812
|
# this, the arity check underestimates because the placeholder count comes from a nil path.
|
|
810
813
|
path = string_argument(node, 0) || hashrocket_path_key(node)
|
|
814
|
+
# `get :activity` inside a `scope(as: :user)` — NOT a resources / member / collection block (that
|
|
815
|
+
# shape returned early above), so the symbol is both the path segment (`/activity`) and the action
|
|
816
|
+
# name. Rails composes `<scope_as>_activity_path`. Fall the symbol back into the path so the
|
|
817
|
+
# name-and-arity derivation below picks it up; without it the route registered nothing and every
|
|
818
|
+
# `user_activity_path` call read as `unknown-helper`.
|
|
819
|
+
path ||= symbol_action_path(node)
|
|
811
820
|
as_name = keyword_symbol(node, :as)
|
|
812
821
|
return if as_name.nil? && path.nil?
|
|
813
822
|
|
|
@@ -844,6 +853,14 @@ module Rigor
|
|
|
844
853
|
end
|
|
845
854
|
end
|
|
846
855
|
|
|
856
|
+
# `/activity` for a bare `get :activity` first-arg symbol (used as both path and action name), else
|
|
857
|
+
# nil. Only reached outside a resources / member / collection block — the symbol-shorthand there is
|
|
858
|
+
# already handled as a member/collection action upstream.
|
|
859
|
+
def symbol_action_path(node)
|
|
860
|
+
symbol = symbol_argument(node, 0)
|
|
861
|
+
symbol && "/#{symbol}"
|
|
862
|
+
end
|
|
863
|
+
|
|
847
864
|
# Builds the helper name for an explicit `get` / `post` / `match` route. Rails uses two distinct
|
|
848
865
|
# shapes:
|
|
849
866
|
#
|
|
@@ -879,12 +896,24 @@ module Rigor
|
|
|
879
896
|
|
|
880
897
|
first_arg = node.arguments&.arguments&.first
|
|
881
898
|
return true if first_arg.is_a?(Prism::SymbolNode)
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
899
|
+
return false unless first_arg.is_a?(Prism::StringNode)
|
|
900
|
+
|
|
901
|
+
path = first_arg.unescaped
|
|
902
|
+
# A dynamic segment (`get 'foo/:id'`) is a real path, not a plain action name — leave it to the
|
|
903
|
+
# generic explicit-route handler.
|
|
904
|
+
return false if path.include?(":")
|
|
905
|
+
|
|
906
|
+
# A bare `get '/'` (or `''`) inside a `collection do` block is the collection root, not an action
|
|
907
|
+
# shorthand — Rails names it after the enclosing resource, not `<empty>_<plural>_path`. An empty
|
|
908
|
+
# normalized action name means "not a shorthand"; fall through to the generic handler.
|
|
909
|
+
return false if path.delete_prefix("/").tr("/", "_").empty?
|
|
910
|
+
|
|
911
|
+
# A single-segment string is an action name in any resource context (`get 'report'` == `get
|
|
912
|
+
# :report`). A MULTI-segment static path (`get 'granular/new'`) is an action too, but only inside
|
|
913
|
+
# an explicit `member do` / `collection do` block, where Rails' `Mapper.normalize_name` maps the
|
|
914
|
+
# slashes to underscores (`granular_new_<scope>_<plural>_path`). Outside such a block (a bare
|
|
915
|
+
# `resources` scope) a multi-segment path is a nested custom route, so keep the generic handler.
|
|
916
|
+
!path.include?("/") || !context.innermost_action_block.nil?
|
|
888
917
|
end
|
|
889
918
|
|
|
890
919
|
# Generates the member / collection action helper.
|
|
@@ -893,8 +922,10 @@ module Rigor
|
|
|
893
922
|
# Collection: `<action>_<plural_helper_prefix>path`,
|
|
894
923
|
# arity = parent_segment_count - 1 (no `:id` segment; the collection URL is /<resource>/<action>).
|
|
895
924
|
def register_member_collection_action(node, context)
|
|
925
|
+
# A multi-segment string action (`get 'granular/new'` in a `collection do` block) is named by
|
|
926
|
+
# Rails via `normalize_name` — leading slash stripped, remaining slashes → underscores.
|
|
896
927
|
action_name = symbol_argument(node, 0)&.to_s ||
|
|
897
|
-
string_argument(node, 0).to_s
|
|
928
|
+
string_argument(node, 0).to_s.delete_prefix("/").tr("/", "_")
|
|
898
929
|
frame = context.innermost_action_block
|
|
899
930
|
# No `member do` / `collection do` wrapper but we're inside a resources block — Rails defaults a
|
|
900
931
|
# bare `<verb> :symbol` inside resources to a MEMBER action (e.g. `get :preview` →
|
|
@@ -5,6 +5,7 @@ require "rigor/plugin"
|
|
|
5
5
|
require_relative "rails_routes/helper_table"
|
|
6
6
|
require_relative "rails_routes/routes_parser"
|
|
7
7
|
require_relative "rails_routes/helper_discoverer"
|
|
8
|
+
require_relative "rails_routes/grape_api_discoverer"
|
|
8
9
|
require_relative "rails_routes/analyzer"
|
|
9
10
|
|
|
10
11
|
module Rigor
|
|
@@ -34,6 +35,11 @@ module Rigor
|
|
|
34
35
|
# config:
|
|
35
36
|
# routes_file: config/routes.rb # default; optional
|
|
36
37
|
#
|
|
38
|
+
# A project mounting a Grape API keeps its `grape-path-helpers` helpers (`api_v4_groups_badges_path`)
|
|
39
|
+
# out of `unknown-helper`: those names come from grape's runtime route table, so {GrapeApiDiscoverer}
|
|
40
|
+
# recognises the namespace their `prefix` / `version` declarations open rather than enumerating names
|
|
41
|
+
# it cannot know.
|
|
42
|
+
#
|
|
37
43
|
# ## Limitations (v0.1.0)
|
|
38
44
|
#
|
|
39
45
|
# - `scope :path:` / `scope :module:` / `scope :as:` are not interpreted — helpers nested inside these
|
|
@@ -50,8 +56,10 @@ module Rigor
|
|
|
50
56
|
# ':project_id', as: :project)` — path read from the `:path` keyword, not only from the positional
|
|
51
57
|
# first arg; (c) detection of iterative `direct(name.sub(FROM, TO)) do ... end` alias-generation
|
|
52
58
|
# idiom — generates `<TO>_*` aliases for every registered `<FROM>_*` helper. GitLab uses this to
|
|
53
|
-
# shorten `namespace_project_*` → `project_*`.
|
|
54
|
-
|
|
59
|
+
# shorten `namespace_project_*` → `project_*`. Bumped 2026-07-10 — recognises the open helper
|
|
60
|
+
# namespace `grape-path-helpers` generates (`api_v4_*_path`), grounded in the project's own Grape
|
|
61
|
+
# `prefix` / `version` declarations (see {GrapeApiDiscoverer}).
|
|
62
|
+
version: "0.28.0",
|
|
55
63
|
description: "Validates Rails route-helper calls against `config/routes.rb`.",
|
|
56
64
|
config_schema: {
|
|
57
65
|
"routes_file" => { kind: :string, default: "config/routes.rb" },
|
|
@@ -63,7 +71,12 @@ module Rigor
|
|
|
63
71
|
# `app/serializers`, `app/presenters`, `app/decorators`, not only `app/helpers/`. Walking the
|
|
64
72
|
# whole tree is the honest answer to "does this `_path` / `_url` name exist anywhere in the
|
|
65
73
|
# project?"; the cost is a one-time Prism parse per file at startup, which is bounded.
|
|
66
|
-
"helper_paths" => { kind: :array, default: ["app"] }
|
|
74
|
+
"helper_paths" => { kind: :array, default: ["app"] },
|
|
75
|
+
# `grape_api_paths` — the directories scanned for Grape API classes, whose `prefix` / `version`
|
|
76
|
+
# declarations ground the open `api_v4_*_path` helper namespace `grape-path-helpers` generates
|
|
77
|
+
# (see {GrapeApiDiscoverer}). Defaults cover the two conventional homes; a project with no grape
|
|
78
|
+
# API declares no prefixes and nothing changes for it.
|
|
79
|
+
"grape_api_paths" => { kind: :array, default: ["lib/api", "app/api"] }
|
|
67
80
|
},
|
|
68
81
|
produces: [:helper_table]
|
|
69
82
|
)
|
|
@@ -73,11 +86,12 @@ module Rigor
|
|
|
73
86
|
# routes-file edits.
|
|
74
87
|
#
|
|
75
88
|
# Passes a `file_reader` lambda so the parser can follow `draw(:admin)` → `config/routes/admin.rb`
|
|
76
|
-
# partials. `watch:` (ADR-60 WD3) covers every `.rb` under `helper_paths:`
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
|
|
89
|
+
# partials. `watch:` (ADR-60 WD3) covers every `.rb` under `helper_paths:` and `grape_api_paths:` so
|
|
90
|
+
# ADDING a helper file — or a Grape API whose `version` declaration opens a new namespace —
|
|
91
|
+
# invalidates the table. The producer's own in-block reads (the routes file + the partials it follows
|
|
92
|
+
# via `draw`) are captured post-compute, but a brand-new file the prior run never read would otherwise
|
|
93
|
+
# read as fresh.
|
|
94
|
+
producer :helper_table, watch: -> { (@helper_paths + @grape_api_paths).map { |dir| [dir, "**/*.rb"] } } do |_p|
|
|
81
95
|
routes_dir = "#{File.dirname(@routes_file)}/routes"
|
|
82
96
|
file_reader = lambda do |name|
|
|
83
97
|
io_boundary.read_file("#{routes_dir}/#{name}")
|
|
@@ -85,13 +99,14 @@ module Rigor
|
|
|
85
99
|
nil
|
|
86
100
|
end
|
|
87
101
|
contents = io_boundary.read_file(@routes_file)
|
|
88
|
-
custom_helpers
|
|
89
|
-
|
|
102
|
+
RoutesParser.parse(contents, file_reader: file_reader, custom_helpers: discover_custom_helpers,
|
|
103
|
+
grape_prefixes: discover_grape_prefixes)
|
|
90
104
|
end
|
|
91
105
|
|
|
92
106
|
def init(_services)
|
|
93
107
|
@routes_file = config.fetch("routes_file")
|
|
94
108
|
@helper_paths = Array(config.fetch("helper_paths")).map(&:to_s)
|
|
109
|
+
@grape_api_paths = Array(config.fetch("grape_api_paths")).map(&:to_s)
|
|
95
110
|
@helper_table = nil
|
|
96
111
|
@helper_table_built = false
|
|
97
112
|
@load_error = nil
|
|
@@ -112,8 +127,23 @@ module Rigor
|
|
|
112
127
|
HelperDiscoverer.discover(contents_per_path)
|
|
113
128
|
end
|
|
114
129
|
|
|
115
|
-
|
|
116
|
-
|
|
130
|
+
# Walks `grape_api_paths:` through the trusted `IoBoundary` and returns the helper-name prefixes the
|
|
131
|
+
# project's Grape API classes generate (`["api_v3", "api_v4"]`). Empty for a project with no grape API,
|
|
132
|
+
# which leaves `unknown-helper` behaviour exactly as it was.
|
|
133
|
+
def discover_grape_prefixes
|
|
134
|
+
contents_per_path = {}
|
|
135
|
+
each_ruby_file(@grape_api_paths) do |path|
|
|
136
|
+
contents_per_path[path] = io_boundary.read_file(path)
|
|
137
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
138
|
+
next
|
|
139
|
+
end
|
|
140
|
+
GrapeApiDiscoverer.discover(contents_per_path)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def each_helper_file(&) = each_ruby_file(@helper_paths, &)
|
|
144
|
+
|
|
145
|
+
def each_ruby_file(dirs, &)
|
|
146
|
+
dirs.each do |dir|
|
|
117
147
|
absolute = File.expand_path(dir)
|
|
118
148
|
next unless File.directory?(absolute)
|
|
119
149
|
|
data/sig/rigor/environment.rbs
CHANGED
|
@@ -29,6 +29,7 @@ module Rigor
|
|
|
29
29
|
def rbs_module?: (String | Symbol name) -> bool
|
|
30
30
|
def class_ordering: (String | Symbol lhs, String | Symbol rhs) -> ordering
|
|
31
31
|
def reflection: () -> untyped?
|
|
32
|
+
def missing_rbs_gem_owner: (String | Symbol root_constant_name) -> String?
|
|
32
33
|
|
|
33
34
|
class ClassRegistry
|
|
34
35
|
def self.default: () -> ClassRegistry
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rigortype
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rigor contributors
|
|
@@ -301,8 +301,10 @@ files:
|
|
|
301
301
|
- data/builtins/ruby_core/string.yml
|
|
302
302
|
- data/builtins/ruby_core/struct.yml
|
|
303
303
|
- data/builtins/ruby_core/time.yml
|
|
304
|
+
- data/core_overlay/csv.rbs
|
|
304
305
|
- data/core_overlay/numeric.rbs
|
|
305
306
|
- data/core_overlay/pathname.rbs
|
|
307
|
+
- data/core_overlay/psych.rbs
|
|
306
308
|
- data/core_overlay/string_scanner.rbs
|
|
307
309
|
- data/gem_overlay/activesupport/core_ext.rbs
|
|
308
310
|
- data/vendored_gem_sigs/ast/ast.rbs
|
|
@@ -484,6 +486,7 @@ files:
|
|
|
484
486
|
- lib/rigor/cli/plugins_command.rb
|
|
485
487
|
- lib/rigor/cli/plugins_renderer.rb
|
|
486
488
|
- lib/rigor/cli/prism_colorizer.rb
|
|
489
|
+
- lib/rigor/cli/protection_fork_scan.rb
|
|
487
490
|
- lib/rigor/cli/protection_renderer.rb
|
|
488
491
|
- lib/rigor/cli/protection_report.rb
|
|
489
492
|
- lib/rigor/cli/renderable.rb
|
|
@@ -511,6 +514,7 @@ files:
|
|
|
511
514
|
- lib/rigor/environment/constant_type_cache_holder.rb
|
|
512
515
|
- lib/rigor/environment/hkt_registry_holder.rb
|
|
513
516
|
- lib/rigor/environment/lockfile_resolver.rb
|
|
517
|
+
- lib/rigor/environment/missing_gem_constant_index.rb
|
|
514
518
|
- lib/rigor/environment/rbs_collection_discovery.rb
|
|
515
519
|
- lib/rigor/environment/rbs_coverage_report.rb
|
|
516
520
|
- lib/rigor/environment/rbs_hierarchy.rb
|
|
@@ -555,6 +559,7 @@ files:
|
|
|
555
559
|
- lib/rigor/inference/fallback.rb
|
|
556
560
|
- lib/rigor/inference/fallback_tracer.rb
|
|
557
561
|
- lib/rigor/inference/flow_tracer.rb
|
|
562
|
+
- lib/rigor/inference/fork_map.rb
|
|
558
563
|
- lib/rigor/inference/hkt_body.rb
|
|
559
564
|
- lib/rigor/inference/hkt_body_parser.rb
|
|
560
565
|
- lib/rigor/inference/hkt_reducer.rb
|
|
@@ -747,6 +752,7 @@ files:
|
|
|
747
752
|
- plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb
|
|
748
753
|
- plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb
|
|
749
754
|
- plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb
|
|
755
|
+
- plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb
|
|
750
756
|
- plugins/rigor-activerecord/sig/active_record/relation.rbs
|
|
751
757
|
- plugins/rigor-activestorage/lib/rigor-activestorage.rb
|
|
752
758
|
- plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb
|
|
@@ -802,6 +808,7 @@ files:
|
|
|
802
808
|
- plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb
|
|
803
809
|
- plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb
|
|
804
810
|
- plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb
|
|
811
|
+
- plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb
|
|
805
812
|
- plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb
|
|
806
813
|
- plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb
|
|
807
814
|
- plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb
|