rigortype 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class RailsRoutes < Rigor::Plugin::Base
8
+ # Walks a parsed file's AST looking for `*_path` /
9
+ # `*_url` calls and validates each against the
10
+ # plugin's {HelperTable}. Emits info diagnostics for
11
+ # recognised helpers and error diagnostics for typos /
12
+ # arity mismatches.
13
+ module Analyzer
14
+ DID_YOU_MEAN_DISTANCE = 3
15
+
16
+ # Built-in Rails helpers we don't want to flag as
17
+ # unknown. The plugin's HelperTable describes
18
+ # user-declared routes; Rails ships built-in helpers
19
+ # (`url_for`, `polymorphic_path`, …) the plugin
20
+ # deliberately ignores.
21
+ BUILTIN_PASSTHROUGH = %w[
22
+ url_for_path url_for_url
23
+ polymorphic_path polymorphic_url
24
+ ].freeze
25
+
26
+ Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
27
+
28
+ module_function
29
+
30
+ # @param path [String] file being analysed
31
+ # @param root [Prism::Node]
32
+ # @param helper_table [HelperTable]
33
+ # @return [Array<Diagnostic>]
34
+ def diagnose(path:, root:, helper_table:)
35
+ diagnostics = []
36
+ walk(root) do |call_node|
37
+ name = call_node.name.to_s
38
+ next unless name.end_with?("_path") || name.end_with?("_url")
39
+ next if BUILTIN_PASSTHROUGH.include?(name)
40
+
41
+ entry = helper_table.find(name)
42
+ if entry
43
+ diagnostics << info_diagnostic(path, call_node, entry)
44
+ arity_diagnostic = arity_check(path, call_node, entry, helper_table)
45
+ diagnostics << arity_diagnostic if arity_diagnostic
46
+ else
47
+ diagnostics << unknown_helper_diagnostic(path, call_node, name, helper_table)
48
+ end
49
+ end
50
+ diagnostics
51
+ end
52
+
53
+ def walk(node, &)
54
+ return unless node.is_a?(Prism::Node)
55
+
56
+ yield node if node.is_a?(Prism::CallNode) && implicit_helper_call?(node)
57
+ node.compact_child_nodes.each { |child| walk(child, &) }
58
+ end
59
+
60
+ # `*_path` / `*_url` calls without an explicit
61
+ # receiver. Calls like `obj.users_path` or
62
+ # `Foo::users_path` are NOT route-helper invocations
63
+ # in Rails — controllers / views call helpers
64
+ # implicitly.
65
+ def implicit_helper_call?(node)
66
+ node.receiver.nil? && (node.name.to_s.end_with?("_path") || node.name.to_s.end_with?("_url"))
67
+ end
68
+
69
+ def info_diagnostic(path, call_node, entry)
70
+ location = call_node.location
71
+ method_label = entry.http_method ? entry.http_method.to_s.upcase : "*"
72
+ Diagnostic.new(
73
+ path: path,
74
+ line: location.start_line,
75
+ column: location.start_column + 1,
76
+ severity: :info,
77
+ rule: "helper",
78
+ message: "`#{entry.name}` → #{method_label} #{entry.path}"
79
+ )
80
+ end
81
+
82
+ def arity_check(path, call_node, entry, helper_table)
83
+ actual = (call_node.arguments&.arguments || []).size
84
+ # Uncountable nouns (`news` / `series` / `media`) cause
85
+ # Rails to register two entries under the same helper
86
+ # name — `news_path` accepts both arity 0 (index) and
87
+ # arity 1 (show). The HelperTable multimap stores both;
88
+ # accepts_arity? checks the full set.
89
+ return nil if helper_table.accepts_arity?(entry.name, actual)
90
+
91
+ arities = helper_table.acceptable_arities(entry.name).sort
92
+ expected = arities.length == 1 ? arities.first.to_s : "#{arities.first}..#{arities.last}"
93
+ location = call_node.location
94
+ Diagnostic.new(
95
+ path: path,
96
+ line: location.start_line,
97
+ column: location.start_column + 1,
98
+ severity: :error,
99
+ rule: "wrong-arity",
100
+ message: "`#{entry.name}` expects #{expected} argument(s), got #{actual}"
101
+ )
102
+ end
103
+
104
+ def unknown_helper_diagnostic(path, call_node, name, helper_table)
105
+ location = call_node.location
106
+ suggestion = did_you_mean(name, helper_table.names)
107
+ message = "no route helper `#{name}`"
108
+ message += " (did you mean `#{suggestion}`?)" if suggestion
109
+
110
+ Diagnostic.new(
111
+ path: path,
112
+ line: location.start_line,
113
+ column: location.start_column + 1,
114
+ severity: :error,
115
+ rule: "unknown-helper",
116
+ message: message
117
+ )
118
+ end
119
+
120
+ # Levenshtein-style nearest neighbour. Returns the
121
+ # closest known helper within {DID_YOU_MEAN_DISTANCE}
122
+ # edits, or nil.
123
+ def did_you_mean(name, candidates)
124
+ best = nil
125
+ best_distance = DID_YOU_MEAN_DISTANCE + 1
126
+ candidates.each do |candidate|
127
+ d = levenshtein(name, candidate)
128
+ if d < best_distance
129
+ best = candidate
130
+ best_distance = d
131
+ end
132
+ end
133
+ best
134
+ end
135
+
136
+ # Standard iterative Levenshtein. Lifted from
137
+ # rigor-routes' equivalent helper for parity.
138
+ def levenshtein(left, right)
139
+ return right.length if left.empty?
140
+ return left.length if right.empty?
141
+
142
+ rows = Array.new(left.length + 1) { Array.new(right.length + 1, 0) }
143
+ (0..left.length).each { |i| rows[i][0] = i }
144
+ (0..right.length).each { |j| rows[0][j] = j }
145
+
146
+ (1..left.length).each do |i|
147
+ (1..right.length).each do |j|
148
+ cost = left[i - 1] == right[j - 1] ? 0 : 1
149
+ rows[i][j] = [
150
+ rows[i - 1][j] + 1,
151
+ rows[i][j - 1] + 1,
152
+ rows[i - 1][j - 1] + cost
153
+ ].min
154
+ end
155
+ end
156
+ rows[left.length][right.length]
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class RailsRoutes < Rigor::Plugin::Base
6
+ # Frozen catalogue of route helpers parsed from
7
+ # `config/routes.rb`. Each entry maps a helper name
8
+ # (`users_path`, `edit_user_path`, …) to the metadata
9
+ # downstream consumers and the analyzer's per-call
10
+ # validation need:
11
+ #
12
+ # - `arity`: number of positional arguments the helper
13
+ # takes. `users_path` → 0; `user_path(:id)` → 1;
14
+ # `user_post_path(:user_id, :id)` → 2.
15
+ # - `path`: the path template Rails generates
16
+ # (`/users/:user_id/posts/:id`).
17
+ # - `http_method`: `:get` / `:post` / `:patch` / `:put` /
18
+ # `:delete` for the canonical action; `nil` for
19
+ # helpers that span multiple methods (a `resources`
20
+ # show helper isn't HTTP-method-specific in the
21
+ # helper sense — it's path-sensitive only).
22
+ # - `action`: `:index` / `:show` / `:new` / `:edit` /
23
+ # `:create` / `:update` / `:destroy` for resourceful
24
+ # routes; `:custom` for explicit `get`/`post`/etc.;
25
+ # `:root` for the root route.
26
+ #
27
+ # Both `_path` and `_url` forms share the same metadata —
28
+ # the table records each helper twice (once with `_path`,
29
+ # once with `_url`) for `O(1)` lookup at the call site.
30
+ class HelperTable
31
+ Entry = Data.define(:name, :arity, :path, :http_method, :action)
32
+
33
+ attr_reader :entries
34
+
35
+ # @param entries [Array<Entry>] freshly built; the
36
+ # factory below is the canonical construction path.
37
+ def initialize(entries)
38
+ @entries = entries.freeze
39
+ # Multimap: a single helper name can map to multiple
40
+ # entries when an uncountable-noun resource registers
41
+ # both an arity-0 index helper and an arity-1 show
42
+ # helper under the same `news_path` name. `find`
43
+ # returns the first entry (preserving the previous
44
+ # API); `accepts_arity?` checks against every entry.
45
+ @by_name = entries.group_by(&:name).transform_values(&:freeze).freeze
46
+ freeze
47
+ end
48
+
49
+ # @return [Entry, nil] First matching entry; for the
50
+ # uncountable-noun case this is the index helper
51
+ # (the show helper is also registered but starts
52
+ # second).
53
+ def find(helper_name)
54
+ @by_name[helper_name.to_s]&.first
55
+ end
56
+
57
+ # @return [Boolean]
58
+ def known?(helper_name)
59
+ @by_name.key?(helper_name.to_s)
60
+ end
61
+
62
+ # @return [Boolean] true when any entry under this
63
+ # helper name accepts the given positional arity.
64
+ def accepts_arity?(helper_name, arity)
65
+ (@by_name[helper_name.to_s] || []).any? { |entry| entry.arity == arity }
66
+ end
67
+
68
+ # @return [Array<Integer>] all accepted positional
69
+ # arities for a helper name. Empty when unknown.
70
+ def acceptable_arities(helper_name)
71
+ (@by_name[helper_name.to_s] || []).map(&:arity).uniq
72
+ end
73
+
74
+ # All helper names — used by the "did you mean" suggester.
75
+ def names
76
+ @by_name.keys
77
+ end
78
+
79
+ def empty?
80
+ @entries.empty?
81
+ end
82
+
83
+ def size
84
+ @entries.size
85
+ end
86
+
87
+ def to_h
88
+ # Plain dump for fact-store publishing (ADR-9). Each
89
+ # name serialises as a small Hash for the FIRST entry
90
+ # under that name, with `acceptable_arities` carrying
91
+ # the full arity set so cross-plugin consumers can
92
+ # honour the uncountable-noun multi-arity case.
93
+ @by_name.transform_values do |group|
94
+ entry = group.first
95
+ { name: entry.name, arity: entry.arity, path: entry.path,
96
+ http_method: entry.http_method, action: entry.action,
97
+ acceptable_arities: group.map(&:arity).uniq }
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end