rigortype 0.1.17 → 0.1.18
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/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules.rb +34 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +160 -1190
- data/lib/rigor/analysis/worker_session.rb +47 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/check_command.rb +705 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli.rb +15 -614
- data/lib/rigor/configuration.rb +9 -6
- data/lib/rigor/environment/rbs_loader.rb +53 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/expression_typer.rb +28 -62
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +10 -11
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher.rb +115 -54
- data/lib/rigor/inference/narrowing.rb +60 -0
- data/lib/rigor/inference/scope_indexer.rb +75 -15
- data/lib/rigor/inference/statement_evaluator.rb +35 -52
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +282 -41
- data/lib/rigor/plugin/node_rule_walk.rb +147 -0
- data/lib/rigor/plugin/registry.rb +263 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +58 -0
- data/lib/rigor/scope.rb +67 -198
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/type/combinator.rb +5 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +1 -2
- data/sig/rigor/scope.rbs +41 -29
- data/sig/rigor/source.rbs +1 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- metadata +15 -2
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "node_context"
|
|
4
|
+
require_relative "../source/node_walker"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Plugin
|
|
8
|
+
# ADR-52 WD4 — one engine-owned AST walk per file for node rules.
|
|
9
|
+
#
|
|
10
|
+
# Before this, every plugin that declared a {Base.node_rule} walked
|
|
11
|
+
# the file's AST itself (`Base#node_rule_diagnostics` →
|
|
12
|
+
# `Source::NodeWalker.each_with_ancestors`), so a project with N
|
|
13
|
+
# node-rule plugins paid N walks per file. This folds them into a
|
|
14
|
+
# single walk that dispatches each visited node to every matching
|
|
15
|
+
# `(plugin, rule)` pair.
|
|
16
|
+
#
|
|
17
|
+
# Behaviour is preserved exactly so the diagnostics stay
|
|
18
|
+
# byte-identical (the WD6 gate):
|
|
19
|
+
#
|
|
20
|
+
# * Each plugin's `node_file_context` block runs once per file,
|
|
21
|
+
# before any of its rules fire, `instance_exec`'d on that plugin —
|
|
22
|
+
# same as the per-plugin walk.
|
|
23
|
+
# * One frozen {NodeContext} is built per node, lazily, only when at
|
|
24
|
+
# least one rule matches it. Because it wraps only the ancestors it
|
|
25
|
+
# is safe to share across plugins for the same node.
|
|
26
|
+
# * Each rule block is `instance_exec`'d on its own plugin instance
|
|
27
|
+
# with the same five arguments `(node, scope, path, file_context,
|
|
28
|
+
# context)`.
|
|
29
|
+
# * A plugin whose context block or any rule block raises has its
|
|
30
|
+
# whole node-rule contribution isolated — the walk records the
|
|
31
|
+
# error against that plugin and continues, matching the runner's
|
|
32
|
+
# per-plugin rescue around the old `#node_rule_diagnostics` call.
|
|
33
|
+
# * Diagnostics are bucketed per plugin and returned in the
|
|
34
|
+
# registry order the runner already iterates, so emission order is
|
|
35
|
+
# unchanged (plugin-major, not node-major) — order preservation is
|
|
36
|
+
# what keeps the gate byte-identical in this slice.
|
|
37
|
+
#
|
|
38
|
+
# The result is an ordered Array of {Result}, one per node-rule
|
|
39
|
+
# plugin (registry order). `Result#error` is non-nil iff that
|
|
40
|
+
# plugin's context or a rule block raised, in which case
|
|
41
|
+
# `#diagnostics` is empty; the runner turns the error into the same
|
|
42
|
+
# per-plugin `runtime-error` envelope it produced before.
|
|
43
|
+
class NodeRuleWalk
|
|
44
|
+
# One plugin's node-rule outcome for a single file. `error` is the
|
|
45
|
+
# exception raised by the plugin's context block or a rule block
|
|
46
|
+
# (nil on success); when set, `diagnostics` is empty.
|
|
47
|
+
Result = Struct.new(:plugin, :diagnostics, :error)
|
|
48
|
+
|
|
49
|
+
# Plugins that declare at least one `node_rule`, paired with their
|
|
50
|
+
# frozen rule list, in registry order. Built once per run and
|
|
51
|
+
# reused for every file.
|
|
52
|
+
def initialize(plugins)
|
|
53
|
+
@entries = plugins.filter_map do |plugin|
|
|
54
|
+
rules = plugin.class.node_rules
|
|
55
|
+
rules.empty? ? nil : [plugin, rules]
|
|
56
|
+
end.freeze
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def empty?
|
|
61
|
+
@entries.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Walk `root` once, dispatching every node to each matching
|
|
65
|
+
# `(plugin, rule)`. Returns an Array of {Result} in plugin
|
|
66
|
+
# (registry) order. `root` nil yields one empty Result per plugin.
|
|
67
|
+
def diagnostics_for_file(path:, scope:, root:)
|
|
68
|
+
return @entries.map { |plugin, _| Result.new(plugin, [], nil) } if root.nil?
|
|
69
|
+
|
|
70
|
+
states = @entries.map { |plugin, rules| State.new(plugin, rules, scope, root) }
|
|
71
|
+
walk(path, scope, root, states)
|
|
72
|
+
states.map(&:result)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def walk(path, scope, root, states)
|
|
78
|
+
Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
|
|
79
|
+
context = nil
|
|
80
|
+
states.each do |state|
|
|
81
|
+
next if state.failed?
|
|
82
|
+
|
|
83
|
+
matched = state.rules_for(node)
|
|
84
|
+
next if matched.empty?
|
|
85
|
+
|
|
86
|
+
# One frozen NodeContext per node, built lazily and shared
|
|
87
|
+
# across every plugin that matches this node.
|
|
88
|
+
context ||= NodeContext.new(ancestors)
|
|
89
|
+
state.run_rules(matched, node, scope, path, context)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Mutable per-(plugin, file) walk state. Kept private to the walk —
|
|
95
|
+
# holds the diagnostics bucket, the file context, the per-concrete-
|
|
96
|
+
# class match memo, and the isolation flag.
|
|
97
|
+
class State
|
|
98
|
+
def initialize(plugin, rules, scope, root)
|
|
99
|
+
@plugin = plugin
|
|
100
|
+
@rules = rules
|
|
101
|
+
@diagnostics = []
|
|
102
|
+
@match_cache = {}.compare_by_identity
|
|
103
|
+
@error = nil
|
|
104
|
+
build_file_context(scope, root)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def failed?
|
|
108
|
+
!@error.nil?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Rules whose `node_type` this concrete node satisfies, memoised
|
|
112
|
+
# by the node's class so the `is_a?` scan runs once per class.
|
|
113
|
+
# Preserves `is_a?` semantics when a rule's `node_type` is a
|
|
114
|
+
# superclass of the concrete node.
|
|
115
|
+
def rules_for(node)
|
|
116
|
+
@match_cache[node.class] ||=
|
|
117
|
+
@rules.select { |rule| node.is_a?(rule[:node_type]) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_rules(matched, node, scope, path, context)
|
|
121
|
+
matched.each do |rule|
|
|
122
|
+
result = @plugin.instance_exec(node, scope, path, @file_context, context, &rule[:block])
|
|
123
|
+
@diagnostics.concat(Array(result))
|
|
124
|
+
end
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
@error = e
|
|
127
|
+
@diagnostics = []
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def result
|
|
131
|
+
NodeRuleWalk::Result.new(@plugin, @diagnostics, @error)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def build_file_context(scope, root)
|
|
137
|
+
block = @plugin.class.node_file_context_block
|
|
138
|
+
@file_context = block ? @plugin.instance_exec(root, scope, &block) : nil
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
@error = e
|
|
141
|
+
@file_context = nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
private_constant :State
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -4,40 +4,224 @@ require_relative "blueprint"
|
|
|
4
4
|
|
|
5
5
|
module Rigor
|
|
6
6
|
module Plugin
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
7
|
+
# ADR-52 WD1 — the compiled contribution table. Categorises a loaded
|
|
8
|
+
# plugin set by which per-call contribution paths each plugin
|
|
9
|
+
# actually implements, AND compiles the declarative gates (method
|
|
10
|
+
# names, `block_as_methods` verbs, `owns_receivers`) into frozen
|
|
11
|
+
# lookup structures, so the engine's hot sites discover "no plugin
|
|
12
|
+
# cares about this call" in O(1) instead of O(plugins × rules) — a
|
|
13
|
+
# top hotspot on plugin-heavy projects (GitLab's 11 plugins, of
|
|
14
|
+
# which only 2 implement any per-call path). Built once per
|
|
15
|
+
# {Registry}.
|
|
16
|
+
#
|
|
17
|
+
# Ordering contract: the gates only PRUNE consultations that could
|
|
18
|
+
# not fire (every pruned rule would have failed its own `methods:` /
|
|
19
|
+
# `verbs:` check); the engine still iterates the plugin subsets in
|
|
20
|
+
# registry order and each plugin's rules in declaration order, so
|
|
21
|
+
# the surviving contributions arrive in exactly the order the
|
|
22
|
+
# ungated walk produced — diagnostics stay byte-identical. The
|
|
13
23
|
# receiver-class ancestry match for `dynamic_return` still happens
|
|
14
|
-
# per-dispatch inside `Plugin::Base#dynamic_return_type
|
|
15
|
-
# prunes plugins that *structurally* cannot contribute, preserving the
|
|
16
|
-
# exact contribution order and result.
|
|
24
|
+
# per-dispatch inside `Plugin::Base#dynamic_return_type`.
|
|
17
25
|
class ContributionIndex
|
|
18
26
|
# Ordered (registry-order) subsets relevant to each collector, and
|
|
19
|
-
# the membership sets used to gate the
|
|
20
|
-
|
|
27
|
+
# the membership sets used to gate the paths within a plugin.
|
|
28
|
+
# `for_file_diagnostics` is the subset the runner's per-file
|
|
29
|
+
# diagnostic loop visits: plugins overriding
|
|
30
|
+
# `#diagnostics_for_file` or declaring at least one `node_rule`.
|
|
31
|
+
attr_reader :for_method_dispatch, :for_statement, :for_file_diagnostics
|
|
32
|
+
|
|
33
|
+
EMPTY_BLOCK_ENTRIES = [].freeze
|
|
34
|
+
private_constant :EMPTY_BLOCK_ENTRIES
|
|
21
35
|
|
|
22
36
|
def initialize(plugins)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@
|
|
26
|
-
@
|
|
27
|
-
|
|
37
|
+
compile_memberships(plugins)
|
|
38
|
+
compile_gates
|
|
39
|
+
@block_entries_by_verb = build_block_entries(plugins)
|
|
40
|
+
@owns_receivers = plugins.flat_map { |p| manifest_for(p)&.owns_receivers || [] }.uniq.freeze
|
|
41
|
+
# Per-run ancestry verdict memo, keyed by environment identity
|
|
42
|
+
# then class name. Mutable inside the frozen index — sound
|
|
43
|
+
# because the class graph is fixed for the lifetime of a run.
|
|
44
|
+
@owns_receiver_memo = {}.compare_by_identity
|
|
28
45
|
freeze
|
|
29
46
|
end
|
|
30
47
|
|
|
31
|
-
def flow?(plugin) = @flow.include?(plugin)
|
|
32
48
|
def dynamic?(plugin) = @dynamic.include?(plugin)
|
|
33
49
|
def type_specifier?(plugin) = @type_specifier.include?(plugin)
|
|
34
50
|
|
|
51
|
+
# O(1) "could any plugin contribute a return type for a call named
|
|
52
|
+
# `method_name`?" — false only when every `dynamic_return` rule is
|
|
53
|
+
# `methods:`-gated on other names, in which case the ungated walk
|
|
54
|
+
# would have produced zero contributions too.
|
|
55
|
+
def dispatch_candidate?(method_name)
|
|
56
|
+
return true if @dynamic_global_gate.nil?
|
|
57
|
+
|
|
58
|
+
@dynamic_global_gate.include?(method_name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# O(1) statement-path sibling of {#dispatch_candidate?} over the
|
|
62
|
+
# `type_specifier` rules (which are always `methods:`-gated).
|
|
63
|
+
def statement_candidate?(method_name)
|
|
64
|
+
return true if @type_specifier_global_gate.nil?
|
|
65
|
+
|
|
66
|
+
@type_specifier_global_gate.include?(method_name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Per-plugin gate: false when the plugin declares no
|
|
70
|
+
# `dynamic_return` rules at all, or when every rule is
|
|
71
|
+
# `methods:`-gated and none lists `method_name` — i.e. when
|
|
72
|
+
# `#dynamic_return_type` would return nil without ever entering a
|
|
73
|
+
# rule block. Subsumes the old `dynamic?(plugin)` membership
|
|
74
|
+
# check at the collector's call site.
|
|
75
|
+
def dynamic_candidate_for?(plugin, method_name)
|
|
76
|
+
return false unless @dynamic.include?(plugin)
|
|
77
|
+
|
|
78
|
+
gate = @dynamic_gates[plugin]
|
|
79
|
+
gate.nil? || gate.include?(method_name)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Per-plugin gate over `type_specifier` rules; same contract as
|
|
83
|
+
# {#dynamic_candidate_for?}.
|
|
84
|
+
def type_specifier_candidate_for?(plugin, method_name)
|
|
85
|
+
return false unless @type_specifier.include?(plugin)
|
|
86
|
+
|
|
87
|
+
gate = @type_specifier_gates[plugin]
|
|
88
|
+
gate.nil? || gate.include?(method_name)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The `Macro::BlockAsMethod` entries whose `verbs` include `verb`,
|
|
92
|
+
# in (plugin registration, manifest declaration) order — the same
|
|
93
|
+
# first-match order the previous plugins × entries walk visited.
|
|
94
|
+
def block_entries_for(verb)
|
|
95
|
+
@block_entries_by_verb.fetch(verb, EMPTY_BLOCK_ENTRIES)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# True when `class_name` equals or inherits from any plugin's
|
|
99
|
+
# manifest-declared `owns_receivers:` entry. The union is compiled
|
|
100
|
+
# at build time (almost always empty → O(1) false) and per-class
|
|
101
|
+
# verdicts memoise per environment.
|
|
102
|
+
def owns_receiver?(class_name, environment)
|
|
103
|
+
return false if @owns_receivers.empty? || class_name.nil?
|
|
104
|
+
|
|
105
|
+
memo = (@owns_receiver_memo[environment] ||= {})
|
|
106
|
+
memo.fetch(class_name) do
|
|
107
|
+
memo[class_name] =
|
|
108
|
+
@owns_receivers.any? { |owner| class_matches_owner?(class_name, owner, environment) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
35
112
|
private
|
|
36
113
|
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
114
|
+
# The categorisation sets + the ordered per-collector subsets.
|
|
115
|
+
def compile_memberships(plugins)
|
|
116
|
+
plugins.each { |p| reject_legacy_flow_hook!(p) }
|
|
117
|
+
@dynamic = plugins.reject { |p| p.class.dynamic_returns.empty? }.to_set
|
|
118
|
+
@type_specifier = plugins.reject { |p| p.class.type_specifiers.empty? }.to_set
|
|
119
|
+
compile_collector_subsets(plugins)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def compile_collector_subsets(plugins)
|
|
123
|
+
@for_method_dispatch = plugins.select { |p| @dynamic.include?(p) }.freeze
|
|
124
|
+
@for_statement = plugins.select { |p| @type_specifier.include?(p) }.freeze
|
|
125
|
+
@for_file_diagnostics =
|
|
126
|
+
plugins.select { |p| file_diagnostics_overridden?(p) || !p.class.node_rules.empty? }.freeze
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The per-plugin and registry-global method-name gates.
|
|
130
|
+
def compile_gates
|
|
131
|
+
@dynamic_gates = build_name_gates(@dynamic) { |p| p.class.dynamic_returns }
|
|
132
|
+
@type_specifier_gates = build_name_gates(@type_specifier) { |p| p.class.type_specifiers }
|
|
133
|
+
@dynamic_global_gate = union_gate(@dynamic_gates)
|
|
134
|
+
@type_specifier_global_gate = union_gate(@type_specifier_gates)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# ADR-52 WD3 — the legacy ungated `flow_contribution_for` hook was
|
|
138
|
+
# deleted pre-1.0. A plugin still defining it would silently never
|
|
139
|
+
# be called, which is the worst failure mode for its author —
|
|
140
|
+
# surface a loud load-time error with the migration mapping
|
|
141
|
+
# instead. (The Base default is gone, so any definer wrote it
|
|
142
|
+
# themselves.)
|
|
143
|
+
def reject_legacy_flow_hook!(plugin)
|
|
144
|
+
return unless plugin.respond_to?(:flow_contribution_for)
|
|
145
|
+
|
|
146
|
+
raise ArgumentError,
|
|
147
|
+
"plugin #{(plugin.class.name || plugin.class).inspect} defines `flow_contribution_for`, " \
|
|
148
|
+
"which was removed (ADR-52). Declare the per-call return type via `dynamic_return` " \
|
|
149
|
+
"(receivers:/methods:/file_methods: gates, static or callable) and post-return narrowing " \
|
|
150
|
+
"facts via `type_specifier` — see the CHANGELOG migration note."
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Same `Method#owner` trick for the per-file diagnostics hook —
|
|
154
|
+
# the Base default returns `[]`, so a non-overriding plugin can be
|
|
155
|
+
# skipped without calling it.
|
|
156
|
+
def file_diagnostics_overridden?(plugin)
|
|
157
|
+
plugin.method(:diagnostics_for_file).owner != Rigor::Plugin::Base
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# `plugin → Set[Symbol] | nil`. A frozen Set when EVERY rule of
|
|
161
|
+
# the plugin is `methods:`-gated (the union of those names); nil
|
|
162
|
+
# when any rule is ungated (the plugin must be consulted for every
|
|
163
|
+
# method name, exactly as before).
|
|
164
|
+
def build_name_gates(members)
|
|
165
|
+
gates = {}
|
|
166
|
+
members.each do |plugin|
|
|
167
|
+
rules = yield(plugin)
|
|
168
|
+
# A static method-name Array can be compiled into the gate. A
|
|
169
|
+
# run-time callable `methods:` (ADR-52 slice 4) is unknown until
|
|
170
|
+
# after `#prepare`, and a receiver-only rule has no `methods:` —
|
|
171
|
+
# either makes the plugin ungated-by-name (nil), consulted on
|
|
172
|
+
# every dispatch and filtered in the instance path.
|
|
173
|
+
gates[plugin] =
|
|
174
|
+
if rules.all? { |rule| rule[:methods].is_a?(Array) }
|
|
175
|
+
rules.flat_map { |rule| rule[:methods] }.to_set.freeze
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
gates.freeze
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The registry-wide union of per-plugin gates, or nil when any
|
|
182
|
+
# plugin is ungated (no global pruning possible). An empty
|
|
183
|
+
# plugin set unions to an empty frozen Set — the collectors'
|
|
184
|
+
# `relevant.empty?` early return fires before it is consulted.
|
|
185
|
+
def union_gate(gates)
|
|
186
|
+
return nil if gates.value?(nil)
|
|
187
|
+
|
|
188
|
+
gates.values.reduce(Set.new) { |acc, names| acc.merge(names) }.freeze
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# `verb Symbol → [BlockAsMethod entries]`, insertion-ordered by
|
|
192
|
+
# (plugin, declaration). Verbs are Symbol-normalised by
|
|
193
|
+
# `Macro::BlockAsMethod#initialize`.
|
|
194
|
+
def build_block_entries(plugins)
|
|
195
|
+
table = {}
|
|
196
|
+
plugins.each do |plugin|
|
|
197
|
+
entries = manifest_for(plugin)&.block_as_methods || []
|
|
198
|
+
entries.each do |entry|
|
|
199
|
+
entry.verbs.each { |verb| (table[verb] ||= []) << entry }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
table.each_value(&:freeze)
|
|
203
|
+
table.freeze
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Mirrors `MethodDispatcher`'s previous per-dispatch matching:
|
|
207
|
+
# exact-name fast path, then `Environment#class_ordering`,
|
|
208
|
+
# degrading to "no match" on any resolution failure.
|
|
209
|
+
def class_matches_owner?(class_name, owner, environment)
|
|
210
|
+
return true if class_name == owner
|
|
211
|
+
return false if environment.nil?
|
|
212
|
+
|
|
213
|
+
%i[equal subclass].include?(environment.class_ordering(class_name, owner))
|
|
214
|
+
rescue StandardError
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Manifest access tolerant of manifest-less plugin doubles in unit
|
|
219
|
+
# specs (a real loaded plugin always carries one — the loader
|
|
220
|
+
# validates it).
|
|
221
|
+
def manifest_for(plugin)
|
|
222
|
+
plugin.manifest
|
|
223
|
+
rescue StandardError
|
|
224
|
+
nil
|
|
41
225
|
end
|
|
42
226
|
end
|
|
43
227
|
|
|
@@ -78,9 +262,29 @@ module Rigor
|
|
|
78
262
|
@load_errors = load_errors.dup.freeze
|
|
79
263
|
@blueprints = blueprints.dup.freeze
|
|
80
264
|
@contribution_index = ContributionIndex.new(@plugins)
|
|
265
|
+
# ADR-52 WD1 — aggregate queries the engine issues per def /
|
|
266
|
+
# per diagnostic candidate / per path are compiled once here
|
|
267
|
+
# (the registry is frozen, so the flat_map-on-every-call
|
|
268
|
+
# versions re-derived an invariant). `@contracts_by_path` is a
|
|
269
|
+
# mutable per-path memo inside the frozen registry — safe
|
|
270
|
+
# because the contract set and the glob semantics are fixed
|
|
271
|
+
# for the lifetime of the run.
|
|
272
|
+
@additional_initializers = @plugins.flat_map { |p| safe_manifest(p)&.additional_initializers || [] }.freeze
|
|
273
|
+
@open_receivers = @plugins.flat_map { |p| (safe_manifest(p)&.open_receivers || []).map(&:to_s) }.uniq.freeze
|
|
274
|
+
@open_receivers_set = @open_receivers.to_set.freeze
|
|
275
|
+
@protocol_contracts = @plugins.flat_map { |p| safe_protocol_contracts(p) }.freeze
|
|
276
|
+
@contracts_by_path = {}
|
|
277
|
+
# ADR-52 WD4 — the single engine-owned node-rule walk, compiled
|
|
278
|
+
# once per run from the node-rule plugin subset (registry order).
|
|
279
|
+
# The runner reuses it for every file; it builds fresh per-file
|
|
280
|
+
# state internally, so it is safe to freeze and share.
|
|
281
|
+
@node_rule_walk = NodeRuleWalk.new(@plugins)
|
|
81
282
|
freeze
|
|
82
283
|
end
|
|
83
284
|
|
|
285
|
+
# ADR-52 WD4 — the per-run node-rule walk (see {NodeRuleWalk}).
|
|
286
|
+
attr_reader :node_rule_walk
|
|
287
|
+
|
|
84
288
|
# ADR-15 Phase 3 — build a fresh Registry from the supplied
|
|
85
289
|
# blueprint set by replaying {Blueprint#materialize} per
|
|
86
290
|
# entry against `services`. The returned registry carries
|
|
@@ -158,14 +362,15 @@ module Rigor
|
|
|
158
362
|
# beyond its RBS-declared method surface. `open_receiver?`
|
|
159
363
|
# is the membership predicate `Analysis::CheckRules` consults
|
|
160
364
|
# to skip the `call.undefined-method` rule for such a class.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
365
|
+
# Compiled at construction (ADR-52 WD1) — the predicate runs per
|
|
366
|
+
# undefined-method candidate, so the previous per-call flat_map
|
|
367
|
+
# re-derived a frozen invariant.
|
|
368
|
+
attr_reader :open_receivers
|
|
164
369
|
|
|
165
370
|
def open_receiver?(class_name)
|
|
166
371
|
return false if class_name.nil?
|
|
167
372
|
|
|
168
|
-
|
|
373
|
+
@open_receivers_set.include?(class_name.to_s)
|
|
169
374
|
end
|
|
170
375
|
|
|
171
376
|
# ADR-28 — flat, ordered list of every loaded plugin's
|
|
@@ -176,10 +381,10 @@ module Rigor
|
|
|
176
381
|
# Consumed by `Inference::MethodParameterBinder` (the
|
|
177
382
|
# parameter-type provision) and by contributing plugins'
|
|
178
383
|
# `#diagnostics_for_file` hooks (the presence + return-type
|
|
179
|
-
# check).
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
384
|
+
# check). Compiled at construction (ADR-52 WD1); a plugin's
|
|
385
|
+
# config-folding `#protocol_contracts` override is stable for the
|
|
386
|
+
# run because config is injected at construction.
|
|
387
|
+
attr_reader :protocol_contracts
|
|
183
388
|
|
|
184
389
|
# ADR-38 — flat, ordered list of every loaded plugin's
|
|
185
390
|
# manifest-declared `Rigor::Plugin::AdditionalInitializer`
|
|
@@ -187,9 +392,9 @@ module Rigor
|
|
|
187
392
|
# read-before-write nil soundness gate: a `def` whose name an
|
|
188
393
|
# entry covers, on a class that equals or inherits from the
|
|
189
394
|
# entry's `receiver_constraint`, is treated like `initialize`.
|
|
190
|
-
def
|
|
191
|
-
|
|
192
|
-
|
|
395
|
+
# Compiled at construction (ADR-52 WD1) — consulted per `def`
|
|
396
|
+
# node, ×2 sites in `ScopeIndexer`.
|
|
397
|
+
attr_reader :additional_initializers
|
|
193
398
|
|
|
194
399
|
# ADR-28 — the subset of `protocol_contracts` whose
|
|
195
400
|
# `path_glob` matches `path`. Contract globs are authored
|
|
@@ -201,11 +406,15 @@ module Rigor
|
|
|
201
406
|
# suffix. `File::FNM_PATHNAME` keeps `*` from crossing `/`;
|
|
202
407
|
# `File::FNM_EXTGLOB` enables `{a,b}` groups. Returns `[]` for
|
|
203
408
|
# a nil path so the binder can call this unconditionally.
|
|
409
|
+
# Memoised per path (ADR-52 WD1) — consulted per `def` node by
|
|
410
|
+
# `MethodParameterBinder`, and the fnmatch sweep over every
|
|
411
|
+
# contract is pure in (contract set, path).
|
|
204
412
|
def contracts_for_path(path)
|
|
205
413
|
return [] if path.nil?
|
|
206
414
|
|
|
207
415
|
path_s = path.to_s
|
|
208
|
-
|
|
416
|
+
@contracts_by_path[path_s] ||=
|
|
417
|
+
protocol_contracts.select { |contract| path_matches_glob?(contract.path_glob, path_s) }.freeze
|
|
209
418
|
end
|
|
210
419
|
|
|
211
420
|
# ADR-32 WD4 + WD5 — flat ordered list of
|
|
@@ -232,14 +441,33 @@ module Rigor
|
|
|
232
441
|
FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
233
442
|
private_constant :FNMATCH_FLAGS
|
|
234
443
|
|
|
235
|
-
EMPTY = new.freeze
|
|
236
|
-
|
|
237
444
|
private
|
|
238
445
|
|
|
239
446
|
def path_matches_glob?(glob, path)
|
|
240
447
|
File.fnmatch?(glob, path, FNMATCH_FLAGS) ||
|
|
241
448
|
File.fnmatch?(File.join("**", glob), path, FNMATCH_FLAGS)
|
|
242
449
|
end
|
|
450
|
+
|
|
451
|
+
# Construction-time manifest access tolerant of manifest-less
|
|
452
|
+
# plugin doubles in unit specs (a real loaded plugin always
|
|
453
|
+
# carries one — the loader validates it). Mirrors
|
|
454
|
+
# `ContributionIndex#manifest_for`.
|
|
455
|
+
def safe_manifest(plugin)
|
|
456
|
+
plugin.manifest
|
|
457
|
+
rescue StandardError
|
|
458
|
+
nil
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def safe_protocol_contracts(plugin)
|
|
462
|
+
plugin.protocol_contracts || []
|
|
463
|
+
rescue StandardError
|
|
464
|
+
[]
|
|
465
|
+
end
|
|
243
466
|
end
|
|
467
|
+
|
|
468
|
+
# Assigned after the class body completes — `Registry.new` runs at
|
|
469
|
+
# assignment time and `#initialize` calls private helpers defined
|
|
470
|
+
# late in the body.
|
|
471
|
+
Registry::EMPTY = Registry.new.freeze
|
|
244
472
|
end
|
|
245
473
|
end
|
data/lib/rigor/plugin.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "plugin/io_boundary"
|
|
|
9
9
|
require_relative "plugin/fact_store"
|
|
10
10
|
require_relative "plugin/services"
|
|
11
11
|
require_relative "plugin/base"
|
|
12
|
+
require_relative "plugin/node_rule_walk"
|
|
12
13
|
require_relative "plugin/registry"
|
|
13
14
|
require_relative "plugin/load_error"
|
|
14
15
|
require_relative "plugin/box"
|
|
@@ -118,12 +118,17 @@ module Rigor
|
|
|
118
118
|
# nil. Mirrors the ADR-35 override checks: covariant return,
|
|
119
119
|
# contravariant params, single method type only, `Dynamic[Top]`
|
|
120
120
|
# positions skipped (fires only on a proven `accepts(...).no?`).
|
|
121
|
+
# Also checks arity divergence and keyword-requiredness divergence
|
|
122
|
+
# (positional-type comparison only was the initial scope; these
|
|
123
|
+
# extend it to the cases that cause runtime ArgumentError).
|
|
121
124
|
def signature_mismatch(required_method, provided_method)
|
|
122
125
|
return nil unless required_method.method_types.size == 1
|
|
123
126
|
return nil unless provided_method.method_types.size == 1
|
|
124
127
|
|
|
125
128
|
return_detail(required_method, provided_method) ||
|
|
126
|
-
param_detail(required_method, provided_method)
|
|
129
|
+
param_detail(required_method, provided_method) ||
|
|
130
|
+
arity_detail(required_method, provided_method) ||
|
|
131
|
+
keyword_detail(required_method, provided_method)
|
|
127
132
|
end
|
|
128
133
|
|
|
129
134
|
def return_detail(required_method, provided_method)
|
|
@@ -153,6 +158,86 @@ module Rigor
|
|
|
153
158
|
nil
|
|
154
159
|
end
|
|
155
160
|
|
|
161
|
+
# Checks positional-count divergence — cases that would cause a
|
|
162
|
+
# runtime `ArgumentError` even when every declared type matches.
|
|
163
|
+
# Skipped when either side has a rest parameter (`*args`) since
|
|
164
|
+
# that makes the arity range unbounded. Two violation shapes:
|
|
165
|
+
# (a) provided requires MORE positionals than the interface allows
|
|
166
|
+
# (caller passes ≤ interface total → provided raises);
|
|
167
|
+
# (b) provided accepts FEWER positionals than the interface requires
|
|
168
|
+
# (caller passes ≥ interface required → provided raises).
|
|
169
|
+
def arity_detail(required_method, provided_method)
|
|
170
|
+
req_func = required_method.method_types.first.type
|
|
171
|
+
prov_func = provided_method.method_types.first.type
|
|
172
|
+
return nil unless req_func.respond_to?(:required_positionals)
|
|
173
|
+
return nil unless prov_func.respond_to?(:required_positionals)
|
|
174
|
+
|
|
175
|
+
req_req = req_func.required_positionals.size
|
|
176
|
+
req_opt = req_func.optional_positionals.size
|
|
177
|
+
prov_req = prov_func.required_positionals.size
|
|
178
|
+
prov_opt = prov_func.required_positionals.size + prov_func.optional_positionals.size
|
|
179
|
+
|
|
180
|
+
# (a) provided requires too many — callers may not pass enough
|
|
181
|
+
if req_func.rest_positionals.nil? && prov_req > req_req + req_opt
|
|
182
|
+
return "requires #{prov_req} positional argument#{'s' if prov_req != 1} " \
|
|
183
|
+
"but the interface allows at most #{req_req + req_opt}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# (b) provided accepts too few — callers will pass more than provided can handle
|
|
187
|
+
if prov_func.rest_positionals.nil? && req_req > prov_opt
|
|
188
|
+
return "accepts at most #{prov_opt} positional argument#{'s' if prov_opt != 1} " \
|
|
189
|
+
"but the interface requires at least #{req_req}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Checks keyword-requiredness divergence.
|
|
196
|
+
# (a) A keyword the interface requires that the provided method does
|
|
197
|
+
# not accept at all → callers will pass it, provided will raise.
|
|
198
|
+
# (b) A keyword the provided method requires that the interface does
|
|
199
|
+
# not mention → callers following the interface won't pass it,
|
|
200
|
+
# provided will raise.
|
|
201
|
+
# Skipped when either side has a keyword rest (`**kwargs`).
|
|
202
|
+
def keyword_detail(required_method, provided_method)
|
|
203
|
+
req_func, prov_func = keyword_funcs(required_method, provided_method)
|
|
204
|
+
return nil unless req_func
|
|
205
|
+
|
|
206
|
+
prov_accepted = accepted_keywords(prov_func)
|
|
207
|
+
req_accepted = accepted_keywords(req_func)
|
|
208
|
+
|
|
209
|
+
not_accepted = req_func.required_keywords.keys - prov_accepted
|
|
210
|
+
return keyword_mismatch_message("does not accept required", not_accepted) if not_accepted.any?
|
|
211
|
+
|
|
212
|
+
extra_required = prov_func.required_keywords.keys - req_accepted
|
|
213
|
+
if extra_required.any?
|
|
214
|
+
return keyword_mismatch_message("requires", extra_required,
|
|
215
|
+
suffix: " not declared by the interface")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def keyword_funcs(required_method, provided_method)
|
|
222
|
+
req_func = required_method.method_types.first.type
|
|
223
|
+
prov_func = provided_method.method_types.first.type
|
|
224
|
+
return [nil, nil] unless req_func.respond_to?(:required_keywords)
|
|
225
|
+
return [nil, nil] unless prov_func.respond_to?(:required_keywords)
|
|
226
|
+
return [nil, nil] unless req_func.rest_keywords.nil? && prov_func.rest_keywords.nil?
|
|
227
|
+
|
|
228
|
+
[req_func, prov_func]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def accepted_keywords(func)
|
|
232
|
+
func.required_keywords.keys + func.optional_keywords.keys
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def keyword_mismatch_message(prefix, kw_keys, suffix: "")
|
|
236
|
+
listed = kw_keys.sort.map { |k| "`#{k}:`" }.join(", ")
|
|
237
|
+
noun = kw_keys.size == 1 ? "keyword" : "keywords"
|
|
238
|
+
"#{prefix} #{noun} #{listed}#{suffix}"
|
|
239
|
+
end
|
|
240
|
+
|
|
156
241
|
def positional_param_types(method_def)
|
|
157
242
|
func = method_def.method_types.first.type
|
|
158
243
|
return nil unless func.respond_to?(:required_positionals)
|