rigortype 0.1.15 → 0.1.16

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +25 -1
  5. data/lib/rigor/analysis/diagnostic.rb +40 -0
  6. data/lib/rigor/analysis/runner.rb +61 -2
  7. data/lib/rigor/analysis/worker_session.rb +3 -2
  8. data/lib/rigor/cache/descriptor.rb +6 -2
  9. data/lib/rigor/cli/plugins_command.rb +51 -4
  10. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  11. data/lib/rigor/cli.rb +135 -5
  12. data/lib/rigor/environment/rbs_loader.rb +259 -1
  13. data/lib/rigor/environment.rb +8 -2
  14. data/lib/rigor/inference/budget_trace.rb +137 -0
  15. data/lib/rigor/inference/expression_typer.rb +9 -2
  16. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  17. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  18. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  19. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  20. data/lib/rigor/inference/precision_scanner.rb +60 -1
  21. data/lib/rigor/inference/scope_indexer.rb +127 -8
  22. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  23. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  24. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  25. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  26. data/lib/rigor/plugin/base.rb +321 -2
  27. data/lib/rigor/plugin/box.rb +64 -0
  28. data/lib/rigor/plugin/inflector.rb +121 -0
  29. data/lib/rigor/plugin/isolation.rb +191 -0
  30. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  31. data/lib/rigor/plugin/macro.rb +1 -0
  32. data/lib/rigor/plugin/manifest.rb +120 -23
  33. data/lib/rigor/plugin/node_context.rb +62 -0
  34. data/lib/rigor/plugin/registry.rb +10 -0
  35. data/lib/rigor/plugin.rb +3 -0
  36. data/lib/rigor/sig_gen/generator.rb +2 -3
  37. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  38. data/lib/rigor/source/literals.rb +118 -0
  39. data/lib/rigor/source/node_walker.rb +26 -0
  40. data/lib/rigor/source.rb +1 -0
  41. data/lib/rigor/type/combinator.rb +6 -1
  42. data/lib/rigor/type/union.rb +65 -1
  43. data/lib/rigor/version.rb +1 -1
  44. data/lib/rigor.rb +1 -0
  45. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  46. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  47. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  48. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  49. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  50. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  51. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  54. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  58. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  59. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  60. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  62. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  63. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  64. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  65. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  66. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  67. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  68. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  69. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  71. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  74. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  75. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  76. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  77. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  78. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  79. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  80. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  81. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  82. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  83. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  84. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  85. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  86. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  87. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  88. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  89. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  90. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  91. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  92. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  93. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  94. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  95. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  96. data/sig/rigor/plugin/base.rbs +58 -3
  97. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  98. data/sig/rigor/plugin/manifest.rbs +31 -1
  99. data/sig/rigor/source.rbs +12 -0
  100. data/sig/rigor.rbs +5 -0
  101. data/skills/rigor-plugin-author/SKILL.md +13 -9
  102. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  103. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  104. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  105. metadata +52 -2
  106. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "did_you_mean"
3
4
  require "digest"
4
5
  require "json"
6
+ require "prism"
5
7
 
6
8
  require_relative "manifest"
9
+ require_relative "node_context"
10
+ require_relative "../analysis/diagnostic"
11
+ require_relative "../source/node_walker"
7
12
 
8
13
  module Rigor
9
14
  module Plugin
@@ -34,7 +39,7 @@ module Rigor
34
39
  # end
35
40
  #
36
41
  # Rigor::Plugin.register(MyRailsPlugin)
37
- class Base
42
+ class Base # rubocop:disable Metrics/ClassLength
38
43
  class << self
39
44
  # Declares the plugin's manifest. Called once at class
40
45
  # definition time — the resulting {Manifest} is cached on
@@ -94,13 +99,168 @@ module Rigor
94
99
  def producers
95
100
  (@producers || {}).dup.freeze
96
101
  end
102
+
103
+ # ADR-37 slice 1 — declares a node-scoped diagnostic rule.
104
+ # The engine owns a single AST walk per file (see
105
+ # {#node_rule_diagnostics}) and dispatches each node to the
106
+ # rules registered for its type, so a plugin author writes the
107
+ # check, never the traversal:
108
+ #
109
+ # class MyPlugin < Rigor::Plugin::Base
110
+ # manifest(id: "demo", version: "0.1.0")
111
+ #
112
+ # node_rule Prism::CallNode do |node, scope, path|
113
+ # next [] unless node.name == :transition_to
114
+ # [diagnostic(node, path: path, message: "…", rule: "x")]
115
+ # end
116
+ # end
117
+ #
118
+ # `node_type` is a `Prism::Node` subclass; the rule fires for
119
+ # every node where `node.is_a?(node_type)`. The block runs
120
+ # through `instance_exec` so `self` is the plugin instance —
121
+ # `config`, `services`, `io_boundary`, `diagnostic`, and the
122
+ # cross-plugin `services.fact_store` are all in scope. It
123
+ # receives `(node, scope, path, file_context, context)` — the
124
+ # fourth argument is the value built by {.node_file_context} for
125
+ # a two-pass plugin (`nil` otherwise); the fifth is a
126
+ # {Rigor::Plugin::NodeContext} carrying the node's lexical
127
+ # ancestors (enclosing class / method / block DSL). Trailing
128
+ # arguments may be omitted from the block's parameter list. The
129
+ # block MUST return an Array of `Rigor::Analysis::Diagnostic`
130
+ # (an empty array to fire nothing); the runner stamps
131
+ # `plugin.<id>` provenance.
132
+ #
133
+ # Multiple rules for the same `node_type` are allowed and run
134
+ # in declaration order. This is the {.producer}-style class DSL
135
+ # rather than a manifest field because a rule carries logic that
136
+ # needs the plugin instance, not pure data.
137
+ def node_rule(node_type, &block)
138
+ raise ArgumentError, "Plugin::Base.node_rule requires a block body" if block.nil?
139
+ unless node_type.is_a?(Class) && node_type <= Prism::Node
140
+ raise ArgumentError,
141
+ "Plugin::Base.node_rule node_type must be a Prism::Node subclass, got #{node_type.inspect}"
142
+ end
143
+
144
+ @node_rules ||= []
145
+ @node_rules << { node_type: node_type, block: block }.freeze
146
+ node_type
147
+ end
148
+
149
+ # Frozen snapshot of the declared node rules, in declaration
150
+ # order. Not inherited from a superclass — like {.producers},
151
+ # the loader instantiates one subclass per registration.
152
+ def node_rules
153
+ (@node_rules || []).dup.freeze
154
+ end
155
+
156
+ # ADR-37 slice 1c — declares a per-file context builder for a
157
+ # two-pass (collect-then-validate) plugin. The block runs once
158
+ # per analysed file (via `instance_exec`, so the plugin instance
159
+ # is `self`) BEFORE any node rule fires, receives `(root, scope)`,
160
+ # and returns an arbitrary file-local value that is threaded to
161
+ # every {.node_rule} block as its fourth argument:
162
+ #
163
+ # node_file_context do |root, _scope|
164
+ # collect_declared_states(root) # the "collect" pass
165
+ # end
166
+ #
167
+ # node_rule Prism::CallNode do |node, _scope, path, states|
168
+ # next [] unless transition_call?(node)
169
+ # validate(node, path, states) # the "validate" pass
170
+ # end
171
+ #
172
+ # This is what lets a same-file two-pass plugin drop its
173
+ # hand-rolled validate walk: the collect pass computes the closed
174
+ # namespace once (it MUST complete before validation because a
175
+ # reference may precede its declaration), and the engine owns the
176
+ # validate walk. A cross-file collect belongs in `#prepare` +
177
+ # `services.fact_store` instead — a node rule reads the fact
178
+ # directly and needs no per-file context.
179
+ #
180
+ # Only one builder per plugin; a second declaration replaces the
181
+ # first. The block result is `nil` when none is declared.
182
+ def node_file_context(&block)
183
+ raise ArgumentError, "Plugin::Base.node_file_context requires a block body" if block.nil?
184
+
185
+ @node_file_context_block = block
186
+ end
187
+
188
+ # The declared per-file context builder block, or nil.
189
+ def node_file_context_block
190
+ defined?(@node_file_context_block) ? @node_file_context_block : nil
191
+ end
192
+
193
+ # ADR-37 slice 2 — declares a per-call-site return-type
194
+ # contribution, receiver-gated. The narrow successor to the
195
+ # `return_type` slot of `flow_contribution_for`:
196
+ #
197
+ # dynamic_return receivers: ["ActiveRecord::Base"] do |call_node, scope|
198
+ # # self = plugin instance; return a Rigor::Type or nil
199
+ # end
200
+ #
201
+ # `receivers:` is a non-empty Array of class names; the engine
202
+ # calls the block only when the call's receiver type's class
203
+ # equals or inherits from one of them (via
204
+ # `Environment#class_ordering`). Method-name and type-shape
205
+ # refinement stays in the block, which returns a `Rigor::Type`
206
+ # (or `nil` to decline). The block runs through `instance_exec`,
207
+ # so `config` / `services` are in scope. This is the
208
+ # {.producer}-style class DSL (it carries logic needing the
209
+ # instance, not pure data).
210
+ def dynamic_return(receivers:, &block)
211
+ raise ArgumentError, "Plugin::Base.dynamic_return requires a block body" if block.nil?
212
+ unless receivers.is_a?(Array) && !receivers.empty? && receivers.all? { |r| r.is_a?(String) && !r.empty? }
213
+ raise ArgumentError,
214
+ "Plugin::Base.dynamic_return receivers: must be a non-empty Array of class-name Strings, " \
215
+ "got #{receivers.inspect}"
216
+ end
217
+
218
+ @dynamic_returns ||= []
219
+ @dynamic_returns << { receivers: receivers.map { |r| r.dup.freeze }.freeze, block: block }.freeze
220
+ nil
221
+ end
222
+
223
+ # Frozen snapshot of the declared dynamic-return rules.
224
+ def dynamic_returns
225
+ (@dynamic_returns || []).dup.freeze
226
+ end
227
+
228
+ # ADR-37 slice 2 — declares a predicate/assertion narrowing
229
+ # contribution, method-gated. The narrow successor to the
230
+ # `post_return_facts` slot of `flow_contribution_for`:
231
+ #
232
+ # type_specifier methods: [:assert_kind_of] do |call_node, scope|
233
+ # # return an Array of post-return facts, or nil
234
+ # end
235
+ #
236
+ # `methods:` is a non-empty Array of method names; the engine
237
+ # calls the block only when `call_node.name` is one of them. The
238
+ # block returns the same `post_return_facts` the merger applies.
239
+ def type_specifier(methods:, &block)
240
+ raise ArgumentError, "Plugin::Base.type_specifier requires a block body" if block.nil?
241
+ unless methods.is_a?(Array) && !methods.empty? &&
242
+ methods.all? { |m| m.is_a?(Symbol) || (m.is_a?(String) && !m.empty?) }
243
+ raise ArgumentError,
244
+ "Plugin::Base.type_specifier methods: must be a non-empty Array of Symbol/String, " \
245
+ "got #{methods.inspect}"
246
+ end
247
+
248
+ @type_specifiers ||= []
249
+ @type_specifiers << { methods: methods.map(&:to_sym).freeze, block: block }.freeze
250
+ nil
251
+ end
252
+
253
+ # Frozen snapshot of the declared type-specifier rules.
254
+ def type_specifiers
255
+ (@type_specifiers || []).dup.freeze
256
+ end
97
257
  end
98
258
 
99
259
  attr_reader :services, :config
100
260
 
101
261
  def initialize(services:, config: {})
102
262
  @services = services
103
- @config = config.freeze
263
+ @config = merge_config_defaults(config).freeze
104
264
  end
105
265
 
106
266
  # Override in subclasses to wire any state the plugin needs
@@ -177,6 +337,129 @@ module Rigor
177
337
  []
178
338
  end
179
339
 
340
+ # ADR-37 slice 1 — runs the plugin's declared {.node_rule}s over
341
+ # one file and returns their diagnostics. The engine owns the
342
+ # single AST walk here so plugin authors never hand-roll a
343
+ # traversal: every node reachable from `root` is offered to each
344
+ # rule whose `node_type` it satisfies (`node.is_a?`), the rule's
345
+ # block is `instance_exec`'d on this plugin instance with
346
+ # `(node, scope, path)`, and the returned diagnostics are
347
+ # concatenated in (node, declaration) order.
348
+ #
349
+ # Returns `[]` immediately when the plugin declares no node rules,
350
+ # so it is a zero-cost no-op for every plugin that does not use
351
+ # the DSL. The runner calls it alongside `#diagnostics_for_file`
352
+ # and stamps `plugin.<id>` provenance on the result; a raise
353
+ # propagates to the runner's per-plugin isolation boundary.
354
+ def node_rule_diagnostics(path:, scope:, root:)
355
+ rules = self.class.node_rules
356
+ return [] if rules.empty? || root.nil?
357
+
358
+ # ADR-37 slice 1c — build the per-file context once (the
359
+ # "collect" pass) before the engine-owned validate walk, so a
360
+ # two-pass plugin sees the closed namespace at every node.
361
+ context_block = self.class.node_file_context_block
362
+ file_context = context_block ? instance_exec(root, scope, &context_block) : nil
363
+
364
+ diagnostics = []
365
+ Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
366
+ rules.each do |rule|
367
+ next unless node.is_a?(rule[:node_type])
368
+
369
+ context = NodeContext.new(ancestors)
370
+ diagnostics.concat(Array(instance_exec(node, scope, path, file_context, context, &rule[:block])))
371
+ end
372
+ end
373
+ diagnostics
374
+ end
375
+
376
+ # ADR-37 slice 2 — the return type contributed by this plugin's
377
+ # {.dynamic_return} rules for a call, or nil. The engine calls this
378
+ # from `MethodDispatcher` alongside (and ahead of) the legacy
379
+ # `flow_contribution_for`; a rule fires only when `receiver_type`'s
380
+ # class equals or inherits from one of its declared `receivers:`.
381
+ # First non-nil wins (declaration order). Failures isolate to nil.
382
+ def dynamic_return_type(call_node:, scope:, receiver_type:)
383
+ rules = self.class.dynamic_returns
384
+ return nil if rules.empty? || receiver_type.nil?
385
+
386
+ class_name = dynamic_return_receiver_class_name(receiver_type)
387
+ return nil if class_name.nil?
388
+
389
+ environment = scope&.environment
390
+ rules.each do |rule|
391
+ next unless rule[:receivers].any? { |c| class_matches_receiver?(class_name, c, environment) }
392
+
393
+ result = instance_exec(call_node, scope, &rule[:block])
394
+ return result if result
395
+ end
396
+ nil
397
+ rescue StandardError
398
+ nil
399
+ end
400
+
401
+ # ADR-37 slice 2 — the post-return narrowing facts contributed by
402
+ # this plugin's {.type_specifier} rules for a call. The engine
403
+ # calls this from `StatementEvaluator` alongside the legacy
404
+ # `flow_contribution_for`; a rule fires only when `call_node.name`
405
+ # is one of its declared `methods:`. Failures isolate to [].
406
+ def type_specifier_facts(call_node:, scope:)
407
+ rules = self.class.type_specifiers
408
+ return [] if rules.empty? || !call_node.respond_to?(:name)
409
+
410
+ name = call_node.name
411
+ facts = []
412
+ rules.each do |rule|
413
+ next unless rule[:methods].include?(name)
414
+
415
+ result = instance_exec(call_node, scope, &rule[:block])
416
+ facts.concat(Array(result)) if result
417
+ end
418
+ facts
419
+ rescue StandardError
420
+ []
421
+ end
422
+
423
+ # Builds a `Rigor::Analysis::Diagnostic` positioned at a Prism
424
+ # `node` for return from `#diagnostics_for_file`. Internalises the
425
+ # 1-based `line` / `start_column + 1` convention every plugin
426
+ # otherwise re-derives by hand, so authors pass the node and the
427
+ # message/severity/rule rather than unpacking `node.location`.
428
+ #
429
+ # `source_family` is intentionally NOT accepted — the runner
430
+ # stamps `plugin.<manifest.id>` on every returned diagnostic
431
+ # (ADR-7 § "Slice 5-B"), so any value set here would be
432
+ # overwritten.
433
+ # Pass `location:` (a Prism location) to point the diagnostic at a
434
+ # sub-location of `node` rather than `node.location` — typically
435
+ # `node.message_loc` so a matcher / method-name diagnostic points
436
+ # at the name, not the receiver-spanning whole call. A `nil`
437
+ # `location:` falls back to `node.location`, so
438
+ # `location: node.message_loc` reproduces the common
439
+ # `message_loc || location` idiom.
440
+ def diagnostic(node, path:, message:, severity: :error, rule: nil, location: nil)
441
+ Analysis::Diagnostic.from_location(
442
+ location || node.location,
443
+ path: path, message: message, severity: severity, rule: rule
444
+ )
445
+ end
446
+
447
+ # Boilerplate-reduction helper (review §1.3): the "did you mean …?"
448
+ # suggestion every diagnostic-emitting plugin otherwise hand-rolls.
449
+ # Returns the closest of `candidates` to `name` via
450
+ # `DidYouMean::SpellChecker` (the same engine Ruby's own
451
+ # `NoMethodError` hints use), or `nil` when there is no good match /
452
+ # no candidates — replacing the per-plugin Levenshtein copies. A
453
+ # **class** method so it is callable both from a plugin instance
454
+ # (`Rigor::Plugin::Base.suggest(...)`) and from an `Analyzer` module
455
+ # function that has no instance.
456
+ def self.suggest(name, candidates)
457
+ dictionary = Array(candidates).map(&:to_s)
458
+ return nil if dictionary.empty?
459
+
460
+ DidYouMean::SpellChecker.new(dictionary: dictionary).correct(name.to_s).first
461
+ end
462
+
180
463
  # Convenience accessor — `manifest` on the instance returns
181
464
  # the class-level manifest declaration.
182
465
  def manifest
@@ -337,6 +620,42 @@ module Rigor
337
620
 
338
621
  private
339
622
 
623
+ # ADR-40 — merge the manifest's declared `config_schema`
624
+ # `default:` values *under* the user-supplied config (user wins),
625
+ # so a plugin reads `config.fetch("key")` and gets the declared
626
+ # default with no `DEFAULT_*` constant. A class declared without a
627
+ # manifest (test doubles) keeps the raw config unchanged.
628
+ def merge_config_defaults(config)
629
+ unless self.class.instance_variable_defined?(:@manifest) && self.class.instance_variable_get(:@manifest)
630
+ return config
631
+ end
632
+
633
+ self.class.manifest.config_defaults.merge(config)
634
+ end
635
+
636
+ # ADR-37 slice 2 — the class name to match a `dynamic_return`
637
+ # `receivers:` entry against, from a receiver `Type`. Covers the
638
+ # instance (`Nominal[X]`) and class (`Singleton[X]`) shapes; other
639
+ # carriers decline (nil → no match).
640
+ def dynamic_return_receiver_class_name(receiver_type)
641
+ case receiver_type
642
+ when Rigor::Type::Nominal, Rigor::Type::Singleton then receiver_type.class_name
643
+ end
644
+ end
645
+
646
+ # True when `class_name` equals or inherits from `constraint`,
647
+ # matched through `Environment#class_ordering` (the mechanism
648
+ # `MacroBlockSelfType` / `additional_initializers` use). Degrades to
649
+ # "no match" on any resolution failure (false-positive-safe).
650
+ def class_matches_receiver?(class_name, constraint, environment)
651
+ return true if class_name == constraint
652
+ return false if environment.nil?
653
+
654
+ %i[equal subclass].include?(environment.class_ordering(class_name, constraint))
655
+ rescue StandardError
656
+ false
657
+ end
658
+
340
659
  def collect_glob_files(roots, patterns)
341
660
  matched = roots.flat_map do |root|
342
661
  absolute = File.expand_path(root.to_s)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # ADR-39 slice 5 — the isolation boundary for target-library
6
+ # invocation. When Rigor is launched with `RUBY_BOX=1` (opt-in; the
7
+ # launcher re-execs only when a project opts in), a target library is
8
+ # loaded and called inside a `Ruby::Box` so its **core-class
9
+ # monkey-patches and gem version cannot contaminate Rigor's main
10
+ # space**. `Ruby::Box` is not a security sandbox (the gem's code still
11
+ # runs in-process) — it is the *contamination* boundary the rule
12
+ # needs, which is sufficient because the rule only ever invokes a
13
+ # declared, trusted, pure target library.
14
+ #
15
+ # **Disabled by default.** {enabled?} is false unless the experimental
16
+ # `Ruby::Box` feature is active, so every consumer keeps a non-box
17
+ # path and the default behaviour (loading the trusted library into the
18
+ # main space, per slices 2/4) is unchanged. The box only ever holds
19
+ # the trusted, project-agnostic target libraries (inflection rules,
20
+ # the Rack status table); per-project / exact-version boxes are a
21
+ # later slice.
22
+ module Box
23
+ module_function
24
+
25
+ # True only when the experimental `Ruby::Box` feature is present and
26
+ # active (the process was started with `RUBY_BOX=1`). Any error
27
+ # answers false so a consumer silently keeps its non-box path.
28
+ def enabled?
29
+ defined?(::Ruby::Box) && ::Ruby::Box.respond_to?(:enabled?) && ::Ruby::Box.enabled?
30
+ rescue StandardError
31
+ false
32
+ end
33
+
34
+ # The process-shared box for trusted, project-agnostic target
35
+ # libraries. Built lazily on first use.
36
+ def shared
37
+ @shared ||= ::Ruby::Box.new
38
+ end
39
+
40
+ # Requires `feature` into the shared box exactly once. Returns true
41
+ # when the feature is available in the box, false when it could not
42
+ # be loaded (the caller then declines — never falls back to loading
43
+ # into the main space, which would defeat the boundary).
44
+ def require_feature(feature)
45
+ @required ||= {}
46
+ return @required[feature] if @required.key?(feature)
47
+
48
+ shared.require(feature)
49
+ @required[feature] = true
50
+ rescue StandardError
51
+ @required[feature] = false
52
+ end
53
+
54
+ # Evaluates `code` inside the shared box and returns the result
55
+ # across the box boundary. The caller MUST build `code` safely —
56
+ # interpolate value arguments through `String#inspect` (which
57
+ # round-trips as a safe Ruby literal) and only ever interpolate
58
+ # method names from a fixed allow-list, never free user input.
59
+ def eval(code)
60
+ shared.eval(code)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # ADR-39 — the shared inflection helper for the Rails-family plugins
6
+ # (`rigor-rails-routes`, `rigor-activerecord`, `rigor-actionpack`).
7
+ #
8
+ # Inflection (`posts` ↔ `post`, `BlogPost` → `blog_posts`) drives
9
+ # route-helper and model-name resolution, so an inflection that
10
+ # diverges from Rails' *actual* rules produces a wrong helper / model
11
+ # name and therefore a **false positive on working code** (a bogus
12
+ # `unknown-helper` / `unknown-permit-key`). Rigor therefore inflects
13
+ # **only** with the real `ActiveSupport::Inflector` — the authority
14
+ # Rails itself uses — and carries **no built-in approximation**: an
15
+ # approximation would be exactly the source of wrong facts the
16
+ # false-positive discipline forbids.
17
+ #
18
+ # Per ADR-39 this is a permitted *target-library* invocation: the
19
+ # methods called are a fixed, pure, allow-listed set
20
+ # ({ALLOWED_METHODS}) on a trusted gem the consuming plugins declare
21
+ # as a dependency. (`ActiveSupport::Inflector` is loaded, not the
22
+ # analyzed application's code; project-specific inflection rules are
23
+ # ingested by *statically parsing* `config/initializers/inflections.rb`
24
+ # in a later slice, never by executing it.)
25
+ #
26
+ # **Absence is silence, never a guess.** When `ActiveSupport::Inflector`
27
+ # cannot be loaded (a misconfiguration — the consuming plugins declare
28
+ # it as a dependency, so it is present in practice), the inflection
29
+ # methods raise {Unavailable} rather than approximate. That raise
30
+ # propagates to the caller's per-plugin rescue boundary, so the
31
+ # inflection-dependent check degrades to **no diagnostics** — reduced
32
+ # coverage, never a wrong fact. A consumer that wants to fail cleanly
33
+ # up front can gate on {available?} and emit a single load-error.
34
+ module Inflector
35
+ # The pure, side-effect-free `ActiveSupport::Inflector` methods this
36
+ # helper is permitted to call (ADR-39 § "safety harness"). The set
37
+ # is fixed and greppable — never a dynamic `public_send`.
38
+ #
39
+ # `tableize` is deliberately NOT delegated: `ActiveSupport::Inflector.
40
+ # tableize("Admin::User")` returns `"admin/users"`, but ActiveRecord's
41
+ # *actual* table name flattens the namespace to `"admin_users"` (the
42
+ # table-name computation does more than the pure `tableize` string
43
+ # method). So {.tableize} composes the AS-backed `underscore` /
44
+ # `pluralize` with the `::`→`_` flattening AR really uses.
45
+ ALLOWED_METHODS = %i[underscore camelize singularize pluralize classify].freeze
46
+
47
+ # The target library + the constant the allow-listed methods are
48
+ # called on. Passed to {Isolation} so the call runs under the
49
+ # configured isolation strategy (none / ruby_box / process).
50
+ FEATURE = "active_support/inflector"
51
+ RECEIVER = "ActiveSupport::Inflector"
52
+
53
+ # Raised when `ActiveSupport::Inflector` is required for an
54
+ # inflection but cannot be loaded. Caught by the per-plugin isolation
55
+ # boundary, so it surfaces as "this plugin produced no diagnostics"
56
+ # rather than a wrong inflection.
57
+ class Unavailable < StandardError
58
+ end
59
+
60
+ module_function
61
+
62
+ # `BlogPost` → `blog_post`; `Admin::Foo` → `admin/foo`.
63
+ def underscore(word)
64
+ invoke(:underscore, word)
65
+ end
66
+
67
+ # `blog_post` → `BlogPost`; `admin/foo` → `Admin::Foo`.
68
+ def camelize(term)
69
+ invoke(:camelize, term)
70
+ end
71
+
72
+ # `posts` → `post`; `categories` → `category`.
73
+ def singularize(word)
74
+ invoke(:singularize, word)
75
+ end
76
+
77
+ # `post` → `posts`; `category` → `categories`.
78
+ def pluralize(word)
79
+ invoke(:pluralize, word)
80
+ end
81
+
82
+ # `posts` → `Post`; `blog_posts` → `BlogPost`.
83
+ def classify(table_name)
84
+ invoke(:classify, table_name)
85
+ end
86
+
87
+ # `BlogPost` → `blog_posts`; `Admin::User` → `admin_users`.
88
+ # Composed (not delegated) so the namespace flattens with `_` the
89
+ # way ActiveRecord's table naming does — see {ALLOWED_METHODS}.
90
+ def tableize(class_name)
91
+ underscored = underscore(class_name.to_s.gsub("::", "/")).tr("/", "_")
92
+ pluralize(underscored)
93
+ end
94
+
95
+ # Whether inflection is available under the configured isolation
96
+ # strategy. A consumer can gate inflection-dependent work on this to
97
+ # emit a single clean load-error instead of letting the first
98
+ # inflection raise. Probes with a trivial pluralization.
99
+ def available?
100
+ invoke(:pluralize, "rigor_inflector_probe")
101
+ true
102
+ rescue Unavailable
103
+ false
104
+ end
105
+
106
+ # Delegates an allow-listed method to the real
107
+ # `ActiveSupport::Inflector` through the configured isolation
108
+ # strategy ({Isolation}: none / ruby_box / process). Raises
109
+ # {Unavailable} (never approximates) when the library cannot be
110
+ # reached in that strategy — the caller's per-plugin rescue turns
111
+ # that into silence, never a wrong inflection.
112
+ def invoke(name, arg)
113
+ raise ArgumentError, "method not allow-listed: #{name}" unless ALLOWED_METHODS.include?(name)
114
+
115
+ Isolation.call(feature: FEATURE, receiver: RECEIVER, method: name, args: [arg.to_s])
116
+ rescue Isolation::Unavailable => e
117
+ raise Unavailable, e.message
118
+ end
119
+ end
120
+ end
121
+ end