rigortype 0.2.8 → 0.3.0

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 (207) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  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/03-narrowing.md +2 -1
  8. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  9. data/docs/handbook/06-classes.md +18 -9
  10. data/docs/handbook/08-understanding-errors.md +4 -3
  11. data/docs/handbook/11-sig-gen.md +11 -1
  12. data/docs/handbook/appendix-mypy.md +4 -3
  13. data/docs/install.md +14 -6
  14. data/docs/manual/01-installation.md +99 -4
  15. data/docs/manual/02-cli-reference.md +65 -19
  16. data/docs/manual/03-configuration.md +34 -2
  17. data/docs/manual/04-diagnostics.md +23 -0
  18. data/docs/manual/07-plugins.md +5 -5
  19. data/docs/manual/09-editor-integration.md +4 -4
  20. data/docs/manual/11-ci.md +51 -0
  21. data/docs/manual/12-caching.md +65 -0
  22. data/docs/manual/14-rails-quickstart.md +4 -2
  23. data/docs/manual/15-type-protection-coverage.md +21 -0
  24. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  25. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  26. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  27. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  28. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  29. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  30. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  31. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  32. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  33. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  34. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  35. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  36. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  37. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  38. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  39. data/lib/rigor/analysis/check_rules.rb +557 -95
  40. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  41. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  42. data/lib/rigor/analysis/incremental_session.rb +456 -51
  43. data/lib/rigor/analysis/path_expansion.rb +42 -0
  44. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  45. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  46. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  47. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  48. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  50. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  52. data/lib/rigor/analysis/runner.rb +343 -68
  53. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  54. data/lib/rigor/analysis/worker_session.rb +30 -2
  55. data/lib/rigor/bleeding_edge.rb +37 -8
  56. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  57. data/lib/rigor/cache/descriptor.rb +88 -28
  58. data/lib/rigor/cache/file_digest.rb +158 -0
  59. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  60. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  61. data/lib/rigor/cache/store.rb +207 -60
  62. data/lib/rigor/ci_detector.rb +90 -0
  63. data/lib/rigor/cli/annotate_command.rb +15 -9
  64. data/lib/rigor/cli/baseline_command.rb +4 -1
  65. data/lib/rigor/cli/check_command.rb +212 -27
  66. data/lib/rigor/cli/coverage_command.rb +56 -30
  67. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  68. data/lib/rigor/cli/docs_command.rb +0 -30
  69. data/lib/rigor/cli/doctor_command.rb +163 -23
  70. data/lib/rigor/cli/lsp_command.rb +5 -0
  71. data/lib/rigor/cli/mcp_command.rb +5 -0
  72. data/lib/rigor/cli/plugins_command.rb +36 -6
  73. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  74. data/lib/rigor/cli/probe_environment.rb +85 -0
  75. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  76. data/lib/rigor/cli/protection_report.rb +7 -1
  77. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  78. data/lib/rigor/cli/skill_command.rb +1 -32
  79. data/lib/rigor/cli/trace_command.rb +5 -6
  80. data/lib/rigor/cli/type_of_command.rb +9 -8
  81. data/lib/rigor/cli/type_scan_command.rb +8 -8
  82. data/lib/rigor/cli.rb +15 -4
  83. data/lib/rigor/config_audit.rb +48 -2
  84. data/lib/rigor/configuration/severity_profile.rb +31 -0
  85. data/lib/rigor/configuration.rb +156 -8
  86. data/lib/rigor/environment/default_libraries.rb +35 -0
  87. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  88. data/lib/rigor/environment/rbs_loader.rb +164 -5
  89. data/lib/rigor/environment.rb +71 -43
  90. data/lib/rigor/inference/budget_trace.rb +77 -2
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  92. data/lib/rigor/inference/def_handle.rb +23 -0
  93. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  94. data/lib/rigor/inference/def_return_typer.rb +2 -1
  95. data/lib/rigor/inference/expression_typer.rb +379 -113
  96. data/lib/rigor/inference/fork_map.rb +87 -0
  97. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  98. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  99. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  100. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  101. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  102. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  103. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  104. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  105. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  106. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  107. data/lib/rigor/inference/mutation_widening.rb +42 -8
  108. data/lib/rigor/inference/narrowing.rb +245 -17
  109. data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
  110. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  111. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  112. data/lib/rigor/inference/scope_indexer.rb +460 -49
  113. data/lib/rigor/inference/statement_evaluator.rb +111 -17
  114. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  115. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  116. data/lib/rigor/inference/void_origin.rb +25 -0
  117. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  118. data/lib/rigor/language_server/completion_provider.rb +2 -1
  119. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  120. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  121. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  122. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  123. data/lib/rigor/plugin/base.rb +9 -22
  124. data/lib/rigor/plugin/inflector.rb +12 -3
  125. data/lib/rigor/plugin/io_boundary.rb +5 -1
  126. data/lib/rigor/plugin/isolation.rb +81 -11
  127. data/lib/rigor/plugin/load_error.rb +10 -1
  128. data/lib/rigor/plugin/loader.rb +96 -14
  129. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  130. data/lib/rigor/plugin/registry.rb +32 -23
  131. data/lib/rigor/plugin.rb +26 -0
  132. data/lib/rigor/protection/mutator.rb +3 -2
  133. data/lib/rigor/reflection.rb +64 -0
  134. data/lib/rigor/runtime/jit.rb +128 -0
  135. data/lib/rigor/scope/discovery_index.rb +12 -2
  136. data/lib/rigor/scope.rb +122 -28
  137. data/lib/rigor/sig_gen/classification.rb +5 -1
  138. data/lib/rigor/sig_gen/generator.rb +38 -6
  139. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  140. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  141. data/lib/rigor/sig_gen/renderer.rb +10 -0
  142. data/lib/rigor/sig_gen/write_result.rb +9 -4
  143. data/lib/rigor/sig_gen/writer.rb +24 -2
  144. data/lib/rigor/source/node_children.rb +116 -0
  145. data/lib/rigor/source/node_locator.rb +3 -1
  146. data/lib/rigor/source/node_walker.rb +4 -2
  147. data/lib/rigor/source.rb +1 -0
  148. data/lib/rigor/type/difference.rb +28 -24
  149. data/lib/rigor/type/hash_shape.rb +34 -10
  150. data/lib/rigor/version.rb +1 -1
  151. data/lib/rigortype.rb +24 -0
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  154. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  165. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  166. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  169. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  170. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  171. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  172. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  173. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  174. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +191 -0
  175. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  176. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  177. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
  178. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  179. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  180. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  181. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  182. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  184. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  185. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  186. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  187. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  188. data/sig/prism_node_children.rbs +9 -0
  189. data/sig/rigor/environment.rbs +1 -0
  190. data/sig/rigor/inference.rbs +7 -0
  191. data/sig/rigor/plugin/base.rbs +2 -3
  192. data/sig/rigor/reflection.rbs +1 -0
  193. data/sig/rigor/scope.rbs +12 -1
  194. data/skills/rigor-ci-setup/SKILL.md +10 -0
  195. data/skills/rigor-editor-setup/SKILL.md +11 -0
  196. data/skills/rigor-next-steps/SKILL.md +23 -2
  197. data/skills/rigor-plugin-author/SKILL.md +3 -3
  198. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  199. data/skills/rigor-plugin-review/SKILL.md +2 -2
  200. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  201. data/skills/rigor-project-init/SKILL.md +29 -3
  202. data/skills/rigor-project-init/references/01-detect.md +1 -1
  203. data/skills/rigor-project-init/references/02-configure.md +39 -5
  204. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  205. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  206. metadata +49 -24
  207. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -122,6 +122,16 @@ module Rigor
122
122
  carried_type(receiver_type, call_node.name)
123
123
  end
124
124
 
125
+ # ADR-88 WD1 — the `dynamic_return` contribution is a PURE STRUCTURAL projection of the receiver's own
126
+ # carrier type args (`Result[T, E]#unwrap → T`), computed per call from the receiver type the engine
127
+ # already resolved; it reads no cross-file catalog. (The `Enum` nested-class emission rides the ADR-16
128
+ # macro substrate / synthetic-method scanner, a separate pre-pass, not this `dynamic_return`.) A stable
129
+ # sentinel declares "no cross-file fact surface", keeping the plugin incremental-capable; the
130
+ # `--verify-incremental` gate backstops the claim.
131
+ def incremental_state_fingerprint
132
+ "structural-carriers"
133
+ end
134
+
125
135
  private
126
136
 
127
137
  # @return [Rigor::Type, nil] the receiver's inferred type, or nil when the engine raises on a synthetic
@@ -62,6 +62,17 @@ module Rigor
62
62
  narrowing_facts methods: AssertionAnalyzer::SUPPORTED_METHODS do |call_node, scope|
63
63
  AssertionAnalyzer.contribution_for(call_node, environment: scope&.environment)&.post_return_facts
64
64
  end
65
+
66
+ # ADR-88 WD1 — the narrowing facts this plugin contributes are derived purely from each assertion call's
67
+ # own AST at its call site (`assert_kind_of(String, x)` ⇒ `x` is `String` on the continuation); there is
68
+ # no cross-file catalog a cached diagnostic could depend on. A change to a file's assertions changes that
69
+ # file's own content (re-analysed by the incremental graph already). A stable sentinel declares "no
70
+ # cross-file fact surface", keeping the plugin incremental-capable (a contributing plugin with NO
71
+ # fact / producer / hook makes the incremental snapshot un-reusable every run); `--verify-incremental`
72
+ # backstops the claim.
73
+ def incremental_state_fingerprint
74
+ "per-file-assertions"
75
+ end
65
76
  end
66
77
 
67
78
  Rigor::Plugin.register(Minitest)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  require_relative "policy_index"
@@ -69,7 +71,7 @@ module Rigor
69
71
  when Prism::ClassNode then visit_class(node, lexical_path, &)
70
72
  when Prism::ModuleNode then visit_module(node, lexical_path, &)
71
73
  else
72
- node.compact_child_nodes.each { |child| walk_for_policies(child, lexical_path, &) }
74
+ node.rigor_each_child { |child| walk_for_policies(child, lexical_path, &) }
73
75
  end
74
76
  end
75
77
 
@@ -1,19 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Convenience entry point. `require "rigor-rails"` requires every Tier 1+2 Rails ecosystem plugin in one go,
4
- # so projects that prefer a single require statement (some `spec_helper` patterns, ad-hoc scripts) do not
5
- # have to list seven require lines.
3
+ # The Tier 1+2 Rails set, as a single require. Requiring this file registers every member class with
4
+ # `Rigor::Plugin`; the loader's lookup phase then finds them by id when `.rigor.yml` enumerates them.
6
5
  #
7
- # Note: requiring this entry point does NOT mark every plugin as active. The Rigor plugin loader walks
8
- # `.rigor.yml`'s `plugins:` list and instantiates only the plugins enumerated there. This is per ADR-12
9
- # WD1's "Gemfile-convenience meta-gem" pattern users still control which plugins participate in analysis
10
- # via `.rigor.yml`.
6
+ # UNWIRED this entry point has no supported use today, and is kept deliberately (ADR-96 WD5) rather
7
+ # than removed. It was ADR-12 WD1's "Gemfile-convenience meta-gem": each plugin was to be its own gem, and
8
+ # `gem "rigor-rails"` was to pull the Rails set into a project's Gemfile in one line. Commit `9769f5fa`
9
+ # dropped the per-plugin gemspecs and ADR-31 settled distribution on the single bundled `rigortype` gem, so
10
+ # there is no `rigor-rails` gem to add to a Gemfile — and `plugins: [rigor-rails]` is rejected by the
11
+ # loader, which requires the members to be enumerated (or one selected with an explicit `id:`).
11
12
  #
12
- # Sub-plugins ARE registered with `Rigor::Plugin` when this file loads (each gem's entry point side-effects
13
- # a `Plugin.register` call); the loader's lookup phase finds them by id when listed.
14
- #
15
- # Adding the gem to a project's Gemfile without listing any plugin in `.rigor.yml` is harmless: the requires
16
- # happen on `Bundler.require`, but no plugin's `init` / `prepare` / hooks run.
13
+ # ADR-96 WD3 proposes the role this file would fill: `plugins: [rigor-rails]` activating those members
14
+ # whose `target_gems:` are actually in the project's `Gemfile.lock`. If that is rejected, ADR-60 WD1's
15
+ # never-wired-surface criterion applies and this directory goes.
17
16
 
18
17
  require "rigor-rails-routes"
19
18
  require "rigor-rails-i18n"
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/source/node_children"
4
+
5
+ require "prism"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class RailsRoutes < Rigor::Plugin::Base
10
+ # Recognises the helper *namespaces* the `grape-path-helpers` gem generates, so a call to one of them
11
+ # does not false-fire `unknown-helper`.
12
+ #
13
+ # The gem names each helper after its route's path segments, joined with `_`
14
+ # (`DecoratedRoute#path_helper_name`): `/api/:version/groups/:id/badges` under `version 'v4'` becomes
15
+ # `api_v4_groups_badges_path`. It walks `Grape::API::Instance.routes` — the *runtime* route table — and
16
+ # real grape sources build that table with metaprogramming (GitLab's `%w[group project].each { |t|
17
+ # resource t.pluralize … }`). A static parser cannot enumerate those names, and deriving only the
18
+ # easy ones would be worse than deriving none: every route it missed would keep firing on working
19
+ # code.
20
+ #
21
+ # What IS static is the leading segments. `prefix :api` contributes the first; `version 'v4'`
22
+ # contributes the second when the strategy is `:path` (Grape's default — `using: :header` / `:param`
23
+ # keeps the version out of the URL, and so out of the helper name). Both are literal arguments in the
24
+ # body of a class whose superclass chain reaches `Grape::API`. Everything after them is opaque.
25
+ #
26
+ # So the namespace `api_v4_…_path` is treated as open — a name the project's routes may define but
27
+ # Rigor cannot enumerate, so proving it undefined is unsound (the ADR-26 `open_receivers` reasoning,
28
+ # already applied in this plugin to Devise's runtime-provider OmniAuth family). Teeth survive where the
29
+ # gem's own contract keeps them sound: it defines no `_url` helper
30
+ # (`NamedRouteMatcher#method_missing` returns `super` unless the name ends `_path`), so
31
+ # `api_v4_anything_url` still fires, as does any name outside a declared prefix.
32
+ #
33
+ # Design note: `docs/notes/20260710-grape-path-helper-namespace.md`.
34
+ module GrapeApiDiscoverer
35
+ # A class is a grape API when its superclass chain reaches one of these. `Grape::API::Instance` is
36
+ # what a mounted `Grape::API` subclass actually becomes, and real code inherits from it directly
37
+ # (GitLab's `API::Base < Grape::API::Instance`).
38
+ GRAPE_BASE_NAMES = ["Grape::API", "Grape::API::Instance"].freeze
39
+
40
+ # `version 'v4', using: :path` puts the version in the URL. `:header` / `:param` do not, so those
41
+ # versions contribute no helper-name segment. Grape's default strategy is `:path`.
42
+ PATH_VERSION_STRATEGY = :path
43
+
44
+ module_function
45
+
46
+ # @param contents_per_path [Hash{String => String}] file path → source text, read by the caller
47
+ # (through the trusted `IoBoundary`, so cache invalidation works).
48
+ # @return [Array<String>] the recognised helper-name prefixes (`["api_v3", "api_v4"]`). Empty when
49
+ # the project declares no grape API — nothing changes for such a project.
50
+ def discover(contents_per_path)
51
+ declarations = {}
52
+ superclasses = {}
53
+ contents_per_path.each_value do |contents|
54
+ collect_from_contents(contents, declarations, superclasses)
55
+ rescue StandardError
56
+ # Best-effort: a parse failure in one grape file must not abort discovery for the rest. The core
57
+ # pipeline surfaces the parse error separately.
58
+ next
59
+ end
60
+ prefixes_for(declarations, superclasses)
61
+ end
62
+
63
+ # Composes `prefix` × `version` into helper-name prefixes, for grape classes only. A class with a
64
+ # prefix and no path-strategy version contributes the bare prefix; one with versions and no prefix
65
+ # contributes each version alone.
66
+ def prefixes_for(declarations, superclasses)
67
+ declarations.filter_map do |class_name, declaration|
68
+ next unless grape_api?(class_name, superclasses)
69
+
70
+ segments = declaration[:prefix]
71
+ versions = declaration[:versions]
72
+ next segments.join("_") if versions.empty? && !segments.empty?
73
+
74
+ versions.map { |version| (segments + [version]).join("_") }
75
+ end.flatten.reject(&:empty?).uniq
76
+ end
77
+
78
+ # Follows the recorded superclass chain (cycle-guarded) looking for a grape base. The chain lives
79
+ # entirely inside the scanned files, so an unresolvable parent simply ends the walk.
80
+ def grape_api?(class_name, superclasses)
81
+ seen = {}
82
+ current = class_name
83
+ while current && !seen[current]
84
+ seen[current] = true
85
+ parent = superclasses[current]
86
+ return true if parent && GRAPE_BASE_NAMES.include?(parent)
87
+
88
+ current = parent
89
+ end
90
+ false
91
+ end
92
+
93
+ def collect_from_contents(contents, declarations, superclasses)
94
+ root = Prism.parse(contents).value
95
+ walk(root, [], declarations, superclasses)
96
+ end
97
+
98
+ def walk(node, prefix_path, declarations, superclasses)
99
+ return unless node.is_a?(Prism::Node)
100
+
101
+ if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
102
+ name = constant_name(node.constant_path)
103
+ if name
104
+ qualified = (prefix_path + [name]).join("::")
105
+ record_class(node, qualified, declarations, superclasses) if node.is_a?(Prism::ClassNode)
106
+ walk(node.body, prefix_path + [name], declarations, superclasses) if node.body
107
+ return
108
+ end
109
+ end
110
+
111
+ node.rigor_each_child { |child| walk(child, prefix_path, declarations, superclasses) }
112
+ end
113
+
114
+ def record_class(node, qualified, declarations, superclasses)
115
+ parent = constant_name(node.superclass)
116
+ superclasses[qualified] = parent if parent
117
+ declaration = { prefix: [], versions: [] }
118
+ statements_of(node.body).each { |statement| absorb_declaration(statement, declaration) }
119
+ declarations[qualified] = declaration unless declaration[:prefix].empty? && declaration[:versions].empty?
120
+ end
121
+
122
+ # `prefix :api` / `prefix 'api/v2'` (a multi-segment prefix splits on `/`), and `version 'v4'` /
123
+ # `version 'v4', using: :path` — both in block and non-block form.
124
+ def absorb_declaration(node, declaration)
125
+ return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
126
+
127
+ case node.name
128
+ when :prefix
129
+ value = literal_value(first_argument(node))
130
+ declaration[:prefix] = value.split("/").reject(&:empty?) if value
131
+ when :version
132
+ value = literal_value(first_argument(node))
133
+ declaration[:versions] << value if value && path_strategy?(node)
134
+ end
135
+ end
136
+
137
+ # True unless an explicit `using:` names a non-path strategy. Grape defaults to `:path`.
138
+ def path_strategy?(node)
139
+ arguments = node.arguments&.arguments || []
140
+ keywords = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
141
+ return true if keywords.nil?
142
+
143
+ using = keywords.elements.find do |element|
144
+ element.is_a?(Prism::AssocNode) && literal_value(element.key) == "using"
145
+ end
146
+ return true if using.nil?
147
+
148
+ literal_value(using.value) == PATH_VERSION_STRATEGY.to_s
149
+ end
150
+
151
+ def first_argument(node)
152
+ node.arguments&.arguments&.first
153
+ end
154
+
155
+ # Symbol and String literals only. A computed prefix / version is not a declaration we can ground a
156
+ # namespace in, so it contributes nothing.
157
+ def literal_value(node)
158
+ case node
159
+ when Prism::SymbolNode, Prism::StringNode then node.unescaped
160
+ end
161
+ end
162
+
163
+ def constant_name(node)
164
+ case node
165
+ when Prism::ConstantReadNode then node.name.to_s
166
+ when Prism::ConstantPathNode then constant_path_name(node)
167
+ end
168
+ end
169
+
170
+ def constant_path_name(node)
171
+ parent = node.parent
172
+ own = node.name&.to_s
173
+ return nil unless own
174
+ return own if parent.nil? # `::Foo` — a cbase root
175
+
176
+ parent_name = constant_name(parent)
177
+ parent_name ? "#{parent_name}::#{own}" : own
178
+ end
179
+
180
+ def statements_of(body)
181
+ case body
182
+ when Prism::StatementsNode then body.body
183
+ when Prism::BeginNode then statements_of(body.statements)
184
+ when nil then []
185
+ else [body]
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  module Rigor
@@ -87,7 +89,7 @@ module Rigor
87
89
  def visit_program_children(node)
88
90
  return unless node.respond_to?(:compact_child_nodes)
89
91
 
90
- node.compact_child_nodes.each do |child|
92
+ node.rigor_each_child do |child|
91
93
  case child
92
94
  when Prism::ModuleNode, Prism::ClassNode
93
95
  visit_module_or_class(child)
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
  require "rigor/source/literals"
5
7
 
@@ -56,9 +58,11 @@ module Rigor
56
58
  # draw partial from `config/routes/name.rb`. Returns file contents
57
59
  # or nil when the file is absent.
58
60
  # @return [HelperTable]
59
- def parse(contents, file_reader: nil, custom_helpers: [])
61
+ def parse(contents, file_reader: nil, custom_helpers: [], grape_prefixes: [])
60
62
  parse_result = Prism.parse(contents)
61
- return HelperTable.new([], custom_helpers: custom_helpers) unless parse_result.errors.empty?
63
+ unless parse_result.errors.empty?
64
+ return HelperTable.new([], custom_helpers: custom_helpers, grape_prefixes: grape_prefixes)
65
+ end
62
66
 
63
67
  context = Context.new(file_reader: file_reader)
64
68
  interpret(parse_result.value, context)
@@ -80,7 +84,8 @@ module Rigor
80
84
  )
81
85
  ]
82
86
  end
83
- HelperTable.new(paired, custom_helpers: custom_helpers, devise_resources: context.devise_resources)
87
+ HelperTable.new(paired, custom_helpers: custom_helpers, devise_resources: context.devise_resources,
88
+ grape_prefixes: grape_prefixes)
84
89
  end
85
90
 
86
91
  # For every registered alias rule `(from_str, to_str, arity_delta)`, find every existing entry whose
@@ -368,7 +373,7 @@ module Rigor
368
373
  when Prism::CallNode
369
374
  interpret_call(node, context)
370
375
  else
371
- node.compact_child_nodes.each { |child| interpret(child, context) }
376
+ node.rigor_each_child { |child| interpret(child, context) }
372
377
  end
373
378
  end
374
379
 
@@ -523,7 +528,7 @@ module Rigor
523
528
  return unless node.is_a?(Prism::Node)
524
529
 
525
530
  yield node
526
- node.compact_child_nodes.each { |child| walk_for_alias_pattern(child, &) }
531
+ node.rigor_each_child { |child| walk_for_alias_pattern(child, &) }
527
532
  end
528
533
 
529
534
  def first_block_parameter_name(block_node)
@@ -542,7 +547,7 @@ module Rigor
542
547
  body = node.block&.body
543
548
  return if body.nil?
544
549
 
545
- body.compact_child_nodes.each { |child| interpret(child, context) }
550
+ body.rigor_each_child { |child| interpret(child, context) }
546
551
  end
547
552
 
548
553
  def handle_namespace(node, context)
@@ -622,7 +627,7 @@ module Rigor
622
627
  return [] if body.nil?
623
628
 
624
629
  skips = []
625
- body.compact_child_nodes.each do |child|
630
+ body.rigor_each_child do |child|
626
631
  next unless child.is_a?(Prism::CallNode) && child.name == :skip_controllers
627
632
  next if child.receiver
628
633
 
@@ -745,7 +750,7 @@ module Rigor
745
750
  body = context.concern_body(concern_name)
746
751
  next if body.nil?
747
752
 
748
- body.compact_child_nodes.each { |child| interpret(child, context) }
753
+ body.rigor_each_child { |child| interpret(child, context) }
749
754
  end
750
755
  end
751
756
 
@@ -808,6 +813,12 @@ module Rigor
808
813
  # String key in the trailing keyword/options hash (its value is the `controller#action`). Without
809
814
  # this, the arity check underestimates because the placeholder count comes from a nil path.
810
815
  path = string_argument(node, 0) || hashrocket_path_key(node)
816
+ # `get :activity` inside a `scope(as: :user)` — NOT a resources / member / collection block (that
817
+ # shape returned early above), so the symbol is both the path segment (`/activity`) and the action
818
+ # name. Rails composes `<scope_as>_activity_path`. Fall the symbol back into the path so the
819
+ # name-and-arity derivation below picks it up; without it the route registered nothing and every
820
+ # `user_activity_path` call read as `unknown-helper`.
821
+ path ||= symbol_action_path(node)
811
822
  as_name = keyword_symbol(node, :as)
812
823
  return if as_name.nil? && path.nil?
813
824
 
@@ -844,6 +855,14 @@ module Rigor
844
855
  end
845
856
  end
846
857
 
858
+ # `/activity` for a bare `get :activity` first-arg symbol (used as both path and action name), else
859
+ # nil. Only reached outside a resources / member / collection block — the symbol-shorthand there is
860
+ # already handled as a member/collection action upstream.
861
+ def symbol_action_path(node)
862
+ symbol = symbol_argument(node, 0)
863
+ symbol && "/#{symbol}"
864
+ end
865
+
847
866
  # Builds the helper name for an explicit `get` / `post` / `match` route. Rails uses two distinct
848
867
  # shapes:
849
868
  #
@@ -879,12 +898,24 @@ module Rigor
879
898
 
880
899
  first_arg = node.arguments&.arguments&.first
881
900
  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?(":")
901
+ return false unless first_arg.is_a?(Prism::StringNode)
902
+
903
+ path = first_arg.unescaped
904
+ # A dynamic segment (`get 'foo/:id'`) is a real path, not a plain action name — leave it to the
905
+ # generic explicit-route handler.
906
+ return false if path.include?(":")
907
+
908
+ # A bare `get '/'` (or `''`) inside a `collection do` block is the collection root, not an action
909
+ # shorthand — Rails names it after the enclosing resource, not `<empty>_<plural>_path`. An empty
910
+ # normalized action name means "not a shorthand"; fall through to the generic handler.
911
+ return false if path.delete_prefix("/").tr("/", "_").empty?
912
+
913
+ # A single-segment string is an action name in any resource context (`get 'report'` == `get
914
+ # :report`). A MULTI-segment static path (`get 'granular/new'`) is an action too, but only inside
915
+ # an explicit `member do` / `collection do` block, where Rails' `Mapper.normalize_name` maps the
916
+ # slashes to underscores (`granular_new_<scope>_<plural>_path`). Outside such a block (a bare
917
+ # `resources` scope) a multi-segment path is a nested custom route, so keep the generic handler.
918
+ !path.include?("/") || !context.innermost_action_block.nil?
888
919
  end
889
920
 
890
921
  # Generates the member / collection action helper.
@@ -893,8 +924,10 @@ module Rigor
893
924
  # Collection: `<action>_<plural_helper_prefix>path`,
894
925
  # arity = parent_segment_count - 1 (no `:id` segment; the collection URL is /<resource>/<action>).
895
926
  def register_member_collection_action(node, context)
927
+ # A multi-segment string action (`get 'granular/new'` in a `collection do` block) is named by
928
+ # Rails via `normalize_name` — leading slash stripped, remaining slashes → underscores.
896
929
  action_name = symbol_argument(node, 0)&.to_s ||
897
- string_argument(node, 0).to_s
930
+ string_argument(node, 0).to_s.delete_prefix("/").tr("/", "_")
898
931
  frame = context.innermost_action_block
899
932
  # No `member do` / `collection do` wrapper but we're inside a resources block — Rails defaults a
900
933
  # 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