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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/08-understanding-errors.md +3 -3
  8. data/docs/manual/01-installation.md +1 -0
  9. data/docs/manual/02-cli-reference.md +16 -7
  10. data/docs/manual/07-plugins.md +1 -1
  11. data/docs/manual/14-rails-quickstart.md +4 -2
  12. data/docs/manual/15-type-protection-coverage.md +21 -0
  13. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  14. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  15. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  16. data/lib/rigor/cache/store.rb +174 -57
  17. data/lib/rigor/cli/coverage_command.rb +50 -29
  18. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  19. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  20. data/lib/rigor/cli/protection_report.rb +7 -1
  21. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  22. data/lib/rigor/environment.rb +54 -18
  23. data/lib/rigor/inference/expression_typer.rb +20 -2
  24. data/lib/rigor/inference/fork_map.rb +87 -0
  25. data/lib/rigor/inference/narrowing.rb +118 -0
  26. data/lib/rigor/inference/parameter_inference_collector.rb +55 -10
  27. data/lib/rigor/inference/scope_indexer.rb +9 -13
  28. data/lib/rigor/inference/statement_evaluator.rb +48 -3
  29. data/lib/rigor/version.rb +1 -1
  30. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -1
  33. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +61 -0
  34. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  35. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  36. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  37. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +189 -0
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +41 -10
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  42. data/sig/rigor/environment.rbs +1 -0
  43. 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
- def initialize(entries, custom_helpers: [], devise_resources: [])
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
- return HelperTable.new([], custom_helpers: custom_helpers) unless parse_result.errors.empty?
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
- # A plain action-name string with no `/` or `:` — treat it as if it were a symbol (`get 'report'`
884
- # == `get :report` inside member/collection block).
885
- first_arg.is_a?(Prism::StringNode) &&
886
- !first_arg.unescaped.include?("/") &&
887
- !first_arg.unescaped.include?(":")
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
- version: "0.27.0",
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:` so ADDING a helper file
77
- # invalidates the tablethe producer's own in-block reads (the routes file + the partials it
78
- # follows via `draw`) are captured post-compute, but a brand-new helper file the prior run never read
79
- # would otherwise read as fresh.
80
- producer :helper_table, watch: -> { @helper_paths.map { |dir| [dir, "**/*.rb"] } } do |_params|
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 = discover_custom_helpers
89
- RoutesParser.parse(contents, file_reader: file_reader, custom_helpers: custom_helpers)
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
- def each_helper_file(&)
116
- @helper_paths.each do |dir|
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
 
@@ -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.8
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