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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/exe/rigor +19 -0
- data/lib/rigor/analysis/check_rules.rb +25 -1
- data/lib/rigor/analysis/diagnostic.rb +40 -0
- data/lib/rigor/analysis/runner.rb +61 -2
- data/lib/rigor/analysis/worker_session.rb +3 -2
- data/lib/rigor/cache/descriptor.rb +6 -2
- data/lib/rigor/cli/plugins_command.rb +51 -4
- data/lib/rigor/cli/plugins_renderer.rb +86 -1
- data/lib/rigor/cli.rb +135 -5
- data/lib/rigor/environment/rbs_loader.rb +259 -1
- data/lib/rigor/environment.rb +8 -2
- data/lib/rigor/inference/budget_trace.rb +137 -0
- data/lib/rigor/inference/expression_typer.rb +9 -2
- data/lib/rigor/inference/hkt_reducer.rb +2 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
- data/lib/rigor/inference/method_dispatcher.rb +57 -10
- data/lib/rigor/inference/precision_scanner.rb +60 -1
- data/lib/rigor/inference/scope_indexer.rb +127 -8
- data/lib/rigor/inference/statement_evaluator.rb +13 -8
- data/lib/rigor/inference/synthetic_method_index.rb +23 -4
- data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
- data/lib/rigor/plugin/additional_initializer.rb +108 -0
- data/lib/rigor/plugin/base.rb +321 -2
- data/lib/rigor/plugin/box.rb +64 -0
- data/lib/rigor/plugin/inflector.rb +121 -0
- data/lib/rigor/plugin/isolation.rb +191 -0
- data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
- data/lib/rigor/plugin/macro.rb +1 -0
- data/lib/rigor/plugin/manifest.rb +120 -23
- data/lib/rigor/plugin/node_context.rb +62 -0
- data/lib/rigor/plugin/registry.rb +10 -0
- data/lib/rigor/plugin.rb +3 -0
- data/lib/rigor/sig_gen/generator.rb +2 -3
- data/lib/rigor/sig_gen/observation_collector.rb +2 -2
- data/lib/rigor/source/literals.rb +118 -0
- data/lib/rigor/source/node_walker.rb +26 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/combinator.rb +6 -1
- data/lib/rigor/type/union.rb +65 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
- data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
- data/sig/rigor/plugin/access_denied_error.rbs +3 -1
- data/sig/rigor/plugin/base.rbs +58 -3
- data/sig/rigor/plugin/io_boundary.rbs +3 -0
- data/sig/rigor/plugin/manifest.rbs +31 -1
- data/sig/rigor/source.rbs +12 -0
- data/sig/rigor.rbs +5 -0
- data/skills/rigor-plugin-author/SKILL.md +13 -9
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
- metadata +52 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -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
|