rigortype 0.4.5 → 0.4.6
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 +1 -1
- data/docs/handbook/11-sig-gen.md +11 -2
- data/docs/manual/02-cli-reference.md +46 -7
- data/docs/manual/03-configuration.md +2 -1
- data/docs/manual/04-diagnostics.md +1 -1
- data/docs/manual/10-mcp-server.md +9 -1
- data/docs/manual/12-caching.md +8 -1
- data/docs/manual/plugins/rigor-actionmailer.md +30 -0
- data/docs/manual/plugins/rigor-actionpack.md +30 -0
- data/docs/manual/plugins/rigor-devise.md +15 -0
- data/docs/manual/plugins/rigor-rspec.md +2 -1
- data/lib/rigor/analysis/buffer_binding.rb +14 -0
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
- data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
- data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
- data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
- data/lib/rigor/analysis/check_rules.rb +93 -8
- data/lib/rigor/analysis/dependency_recorder.rb +11 -0
- data/lib/rigor/analysis/edit_replay.rb +67 -0
- data/lib/rigor/analysis/incremental.rb +37 -0
- data/lib/rigor/analysis/incremental_session.rb +175 -27
- data/lib/rigor/analysis/path_expansion.rb +41 -0
- data/lib/rigor/analysis/require_relative_closure.rb +100 -0
- data/lib/rigor/analysis/rule_catalog.rb +5 -1
- data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
- data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
- data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
- data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
- data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
- data/lib/rigor/analysis/runner.rb +156 -49
- data/lib/rigor/analysis/worker_session.rb +14 -2
- data/lib/rigor/cache/incremental_snapshot.rb +30 -7
- data/lib/rigor/cli/check_command.rb +93 -4
- data/lib/rigor/cli/diff_command.rb +2 -1
- data/lib/rigor/cli/lens_command.rb +5 -5
- data/lib/rigor/cli/mcp_command.rb +2 -1
- data/lib/rigor/cli/options.rb +20 -3
- data/lib/rigor/cli/plugins_command.rb +5 -0
- data/lib/rigor/cli/plugins_renderer.rb +24 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +46 -7
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/type_of_project_scope.rb +27 -0
- data/lib/rigor/config_audit.rb +16 -5
- data/lib/rigor/configuration.rb +14 -4
- data/lib/rigor/environment/rbs_loader.rb +115 -6
- data/lib/rigor/environment/required_features.rb +136 -29
- data/lib/rigor/environment/ruby_library_roots.rb +122 -0
- data/lib/rigor/environment.rb +4 -2
- data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
- data/lib/rigor/inference/deep_erasure.rb +138 -0
- data/lib/rigor/inference/def_return_typer.rb +159 -9
- data/lib/rigor/inference/definer_resolution.rb +17 -3
- data/lib/rigor/inference/error_info.rb +17 -3
- data/lib/rigor/inference/expression_typer.rb +62 -13
- data/lib/rigor/inference/global_write_census.rb +36 -1
- data/lib/rigor/inference/ivar_writer_census.rb +195 -0
- data/lib/rigor/inference/loop_value_record.rb +53 -0
- data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
- data/lib/rigor/inference/method_dispatcher.rb +46 -16
- data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
- data/lib/rigor/inference/module_function_state.rb +12 -0
- data/lib/rigor/inference/mutation_widening.rb +5 -2
- data/lib/rigor/inference/narrowing.rb +66 -5
- data/lib/rigor/inference/object_mixins.rb +13 -1
- data/lib/rigor/inference/project_seed_tables.rb +2 -1
- data/lib/rigor/inference/project_yielder.rb +128 -0
- data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
- data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
- data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
- data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
- data/lib/rigor/inference/scope_indexer.rb +480 -103
- data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
- data/lib/rigor/inference/statement_evaluator.rb +658 -90
- data/lib/rigor/inference/synthetic_method_index.rb +23 -0
- data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
- data/lib/rigor/language_server/buffer_table.rb +6 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
- data/lib/rigor/language_server/project_context.rb +59 -3
- data/lib/rigor/language_server/server.rb +5 -3
- data/lib/rigor/mcp/server.rb +9 -1
- data/lib/rigor/plugin/base.rb +26 -11
- data/lib/rigor/plugin/manifest.rb +29 -3
- data/lib/rigor/plugin/registry.rb +38 -0
- data/lib/rigor/protection/discovery_seed.rb +57 -2
- data/lib/rigor/rooted_glob.rb +10 -1
- data/lib/rigor/scope.rb +14 -5
- data/lib/rigor/sig_gen/generator.rb +308 -37
- data/lib/rigor/sig_gen/observation_collector.rb +4 -1
- data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
- data/lib/rigor/sig_gen/renderer.rb +6 -3
- data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
- data/lib/rigor/type/combinator.rb +4 -3
- data/lib/rigor/type/constant.rb +45 -3
- data/lib/rigor/type/hash_shape.rb +14 -9
- data/lib/rigor/type/struct_instance.rb +3 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +3 -2
- data/sig/rigor/effects/file_collection.rbs +5 -6
- data/sig/rigor/effects/registry.rbs +8 -6
- data/sig/rigor/effects/summary.rbs +6 -5
- data/sig/rigor/environment.rbs +5 -3
- data/sig/rigor/inference/optimistic_origin.rbs +11 -0
- data/sig/rigor/inference.rbs +6 -0
- data/sig/rigor/plugin/additional_initializer.rbs +5 -5
- data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
- data/sig/rigor/plugin/effect_attribution.rbs +25 -10
- data/sig/rigor/plugin/effect_edge.rbs +12 -5
- data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
- data/sig/rigor/plugin/io_boundary.rbs +4 -4
- data/sig/rigor/plugin/manifest.rbs +16 -18
- data/sig/rigor/plugin/protocol_contract.rbs +10 -9
- data/sig/rigor/plugin/registry.rbs +1 -1
- data/sig/rigor/scope.rbs +31 -50
- data/sig/rigor/type.rbs +0 -6
- metadata +20 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
require_relative "synthetic_method"
|
|
4
6
|
|
|
5
7
|
module Rigor
|
|
@@ -65,8 +67,29 @@ module Rigor
|
|
|
65
67
|
{ "entries" => entries.map(&:to_h), "class_names" => class_names }
|
|
66
68
|
end
|
|
67
69
|
|
|
70
|
+
# What a dispatch can read from the index, digested (nil for the empty index): each key's rows in their
|
|
71
|
+
# first-wins order, with the return type and the Tier B `origin_module` the dispatcher promotes through,
|
|
72
|
+
# and the synthesised class names. The rest of the provenance (the call site's path and line) is left
|
|
73
|
+
# out, so an edit that only moves the `devise` line keeps the digest. `IncrementalSession` compares it
|
|
74
|
+
# across runs (PR #2286 review M1): the index is built from class-body calls that no declaration
|
|
75
|
+
# signature records, so a recheck that sees it move cannot trust its served files.
|
|
76
|
+
def surface_digest
|
|
77
|
+
return nil if empty?
|
|
78
|
+
|
|
79
|
+
rows = entries.each_with_index
|
|
80
|
+
.sort_by { |entry, index| [entry.kind.to_s, entry.class_name, entry.method_name.to_s, index] }
|
|
81
|
+
.map { |entry, _| surface_row(entry) }
|
|
82
|
+
Digest::SHA256.hexdigest((rows + class_names.sort.map { |name| "class\x1f#{name}" }).join("\x00"))
|
|
83
|
+
end
|
|
84
|
+
|
|
68
85
|
EMPTY_ROW = [].freeze
|
|
69
86
|
|
|
87
|
+
def surface_row(entry)
|
|
88
|
+
[entry.kind, entry.class_name, entry.method_name, entry.return_type,
|
|
89
|
+
entry.provenance[:origin_module], entry.provenance[:plugin_id]].join("\x1f")
|
|
90
|
+
end
|
|
91
|
+
private :surface_row
|
|
92
|
+
|
|
70
93
|
def bucket(entries, kind)
|
|
71
94
|
h = {}
|
|
72
95
|
entries.each do |entry|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../../source/literals"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Inference
|
|
8
|
+
module SyntheticMethodScanner
|
|
9
|
+
# The instance-method names a project class answers ahead of a module a trait registry includes into it
|
|
10
|
+
# (PR #2286 review S1). Ruby looks a method up on the class itself before any module it includes, and a
|
|
11
|
+
# module the class itself includes or prepends sits ahead of one `devise` included earlier — so where
|
|
12
|
+
# the project names a member, the Tier B entry for it would answer the call from the wrong rung:
|
|
13
|
+
#
|
|
14
|
+
# class User < ApplicationRecord
|
|
15
|
+
# devise :confirmable
|
|
16
|
+
# include PasswordPolicy # defines password_required?
|
|
17
|
+
# def confirmed? = :yes
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# `User.new.confirmed?` is `:yes` and `password_required?` is the policy's, not `bool` from the Devise
|
|
21
|
+
# module's RBS. {SyntheticMethodScanner} therefore drops a trait entry whose name this index reports.
|
|
22
|
+
#
|
|
23
|
+
# A name counts as the class's when any reopening of the class body (in any project file) writes `def`,
|
|
24
|
+
# `alias` / `alias_method`, `attr_*`, `define_method` or `delegate` for it, or when a project module the
|
|
25
|
+
# class includes or prepends (followed through that module's own `include` / `prepend`) does. The MRO
|
|
26
|
+
# order of an `include` relative to the `devise` call is NOT consulted: a module included before the
|
|
27
|
+
# call sits behind the Devise module at runtime, but the name is declined anyway. Declining is the
|
|
28
|
+
# conservative direction — the call then reaches the tiers below the synthetic one (the discovered
|
|
29
|
+
# method, the project mixin), which answer it as they did before Tier B was reachable. A mixin constant
|
|
30
|
+
# is matched to a project module by name or by `::`-suffix, again widening only what is declined.
|
|
31
|
+
class ProjectMembers
|
|
32
|
+
# The calls that name members through their literal arguments, and the suffixes each name takes.
|
|
33
|
+
SUFFIXES_BY_CALL = Ractor.make_shareable(
|
|
34
|
+
{
|
|
35
|
+
attr_reader: [""], attr: [""], attr_writer: ["="], attr_accessor: ["", "="], delegate: [""]
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
NAME_ARG_CALLS = %i[define_method alias_method].freeze
|
|
39
|
+
MIXIN_CALLS = %i[include prepend].freeze
|
|
40
|
+
|
|
41
|
+
Entry = Struct.new(:names, :mixins)
|
|
42
|
+
private_constant :Entry
|
|
43
|
+
|
|
44
|
+
def initialize(asts)
|
|
45
|
+
@classes = Hash.new { |hash, key| hash[key] = Entry.new(Set.new, []) }
|
|
46
|
+
@modules = Hash.new { |hash, key| hash[key] = Entry.new(Set.new, []) }
|
|
47
|
+
asts.each_value { |ast| walk_namespaces(ast, []) unless ast.nil? }
|
|
48
|
+
@declared = {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# True when `class_name`'s own body, or a project module it includes or prepends, names `method_name`.
|
|
52
|
+
def declares?(class_name, method_name)
|
|
53
|
+
(@declared[class_name] ||= declared_names(class_name)).include?(method_name.to_sym)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def declared_names(class_name)
|
|
59
|
+
entry = @classes.fetch(class_name, nil)
|
|
60
|
+
return Set.new.freeze if entry.nil?
|
|
61
|
+
|
|
62
|
+
names = entry.names.dup
|
|
63
|
+
visited = Set.new
|
|
64
|
+
queue = entry.mixins.dup
|
|
65
|
+
until queue.empty?
|
|
66
|
+
module_keys_for(queue.shift).each do |key|
|
|
67
|
+
next unless visited.add?(key)
|
|
68
|
+
|
|
69
|
+
names.merge(@modules[key].names)
|
|
70
|
+
queue.concat(@modules[key].mixins)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
names.freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def module_keys_for(name)
|
|
77
|
+
@modules.keys.select { |key| key == name || key.end_with?("::#{name}") }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def walk_namespaces(node, scope_stack)
|
|
81
|
+
case node
|
|
82
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
83
|
+
name = SyntheticMethodScanner.class_name_from(node, scope_stack)
|
|
84
|
+
if name
|
|
85
|
+
table = node.is_a?(Prism::ClassNode) ? @classes : @modules
|
|
86
|
+
collect_members(node.body, table[name])
|
|
87
|
+
end
|
|
88
|
+
inner = scope_stack + [node]
|
|
89
|
+
node.body&.rigor_each_child { |child| walk_namespaces(child, inner) }
|
|
90
|
+
else
|
|
91
|
+
node.rigor_each_child { |child| walk_namespaces(child, scope_stack) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Every member-naming form under the namespace body, at any depth short of a nested namespace (a
|
|
96
|
+
# `private def`, a `def` under a conditional and an `included do ... def` all count). A nested `class`,
|
|
97
|
+
# `module` or `class << self` is another namespace's body.
|
|
98
|
+
def collect_members(node, entry)
|
|
99
|
+
return if node.nil?
|
|
100
|
+
|
|
101
|
+
case node
|
|
102
|
+
when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then return
|
|
103
|
+
when Prism::DefNode then entry.names << node.name if node.receiver.nil?
|
|
104
|
+
when Prism::AliasMethodNode then add_symbol(entry, node.new_name)
|
|
105
|
+
when Prism::CallNode then collect_call(node, entry)
|
|
106
|
+
end
|
|
107
|
+
node.rigor_each_child { |child| collect_members(child, entry) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def collect_call(call, entry)
|
|
111
|
+
return unless call.receiver.nil? || call.receiver.is_a?(Prism::SelfNode)
|
|
112
|
+
|
|
113
|
+
args = call.arguments&.arguments || []
|
|
114
|
+
if MIXIN_CALLS.include?(call.name)
|
|
115
|
+
entry.mixins.concat(args.filter_map { |arg| SyntheticMethodScanner.const_name_string(arg) })
|
|
116
|
+
elsif NAME_ARG_CALLS.include?(call.name)
|
|
117
|
+
add_symbol(entry, args.first)
|
|
118
|
+
elsif (suffixes = SUFFIXES_BY_CALL[call.name])
|
|
119
|
+
args.each { |arg| suffixes.each { |suffix| add_symbol(entry, arg, suffix: suffix) } }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_symbol(entry, node, suffix: "")
|
|
124
|
+
value = Source::Literals.symbol_or_string_name(node)
|
|
125
|
+
entry.names << :"#{value}#{suffix}" unless value.nil?
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -8,6 +8,7 @@ require_relative "../source/literals"
|
|
|
8
8
|
require_relative "../source/node_children"
|
|
9
9
|
require_relative "synthetic_method"
|
|
10
10
|
require_relative "synthetic_method_index"
|
|
11
|
+
require_relative "synthetic_method_scanner/project_members"
|
|
11
12
|
|
|
12
13
|
module Rigor
|
|
13
14
|
module Inference
|
|
@@ -64,17 +65,30 @@ module Rigor
|
|
|
64
65
|
nested_templates = collect_nested_class_templates(plugin_registry)
|
|
65
66
|
return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty? && nested_templates.empty?
|
|
66
67
|
# Tier B alone cannot emit without an environment: every trait entry — the direct route and the
|
|
67
|
-
# concern-re-targeted one — funnels through `module_instance_method_names`, whose first line
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
68
|
+
# concern-re-targeted one — funnels through `module_instance_method_names`, whose first line answers `[]`
|
|
69
|
+
# for a nil environment. The production pre-pass hands Tier B a (lazy) environment since #476, so this
|
|
70
|
+
# gate now guards the direct callers that pass none (a spec, a probe): a registry-only scan with
|
|
71
|
+
# no environment would otherwise pay a whole-project parse to build a provably empty index. It mirrors
|
|
72
|
+
# that nil guard and goes with it.
|
|
72
73
|
return SyntheticMethodIndex::EMPTY if environment.nil? && templates.empty? && nested_templates.empty?
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
sources = read_sources(paths, buffer: buffer)
|
|
76
|
+
# With only trait registries contributing, a project that never writes a registry's method name (no
|
|
77
|
+
# `devise` anywhere) cannot produce an entry: a call node named `devise` needs the text `devise` in its
|
|
78
|
+
# file. Skip the parse (the dominant cost) then, and the environment build it would have asked for.
|
|
79
|
+
return SyntheticMethodIndex::EMPTY if templates.empty? && nested_templates.empty? &&
|
|
80
|
+
none_mention_registry_method?(sources, registries)
|
|
81
|
+
|
|
82
|
+
asts = parse_sources(sources)
|
|
83
|
+
entries, class_names = collect_all_entries(asts, templates, registries, nested_templates, environment,
|
|
84
|
+
fact_store)
|
|
85
|
+
SyntheticMethodIndex.new(entries: decline_project_declared(entries, asts), class_names: class_names)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The match + emit walk over every parsed file: `[entries, class_names]`.
|
|
89
|
+
def collect_all_entries(asts, templates, registries, nested_templates, environment, fact_store)
|
|
75
90
|
hierarchy = build_hierarchy(asts)
|
|
76
91
|
concern_index = build_concern_index(asts)
|
|
77
|
-
|
|
78
92
|
entries = []
|
|
79
93
|
class_names = []
|
|
80
94
|
asts.each do |path, ast|
|
|
@@ -88,8 +102,19 @@ module Rigor
|
|
|
88
102
|
end
|
|
89
103
|
collect_nested_class_entries(entries, class_names, nested_templates, ast, path) unless nested_templates.empty?
|
|
90
104
|
end
|
|
105
|
+
[entries, class_names]
|
|
106
|
+
end
|
|
91
107
|
|
|
92
|
-
|
|
108
|
+
# PR #2286 review S1 — a Tier B entry whose name the class itself declares, or a project module it includes
|
|
109
|
+
# or prepends declares, is dropped: Ruby answers that call from the project's member, ahead of the module
|
|
110
|
+
# the registry included ({ProjectMembers}). Tier C entries (no `origin_module`) are left as they were.
|
|
111
|
+
def decline_project_declared(entries, asts)
|
|
112
|
+
return entries unless entries.any? { |entry| entry.provenance[:origin_module] }
|
|
113
|
+
|
|
114
|
+
members = ProjectMembers.new(asts)
|
|
115
|
+
entries.reject do |entry|
|
|
116
|
+
entry.provenance[:origin_module] && members.declares?(entry.class_name, entry.method_name)
|
|
117
|
+
end
|
|
93
118
|
end
|
|
94
119
|
|
|
95
120
|
# Aggregates `(plugin_id, template)` pairs across every plugin's `manifest.heredoc_templates` in registration
|
|
@@ -249,16 +274,32 @@ module Rigor
|
|
|
249
274
|
call_node.arguments&.arguments&.first
|
|
250
275
|
end
|
|
251
276
|
|
|
252
|
-
|
|
277
|
+
# `path => source text` (nil when the file cannot be read), reading an editor buffer's in-flight bytes
|
|
278
|
+
# for the logical path it is bound to.
|
|
279
|
+
def read_sources(paths, buffer: nil)
|
|
253
280
|
paths.to_h do |path|
|
|
254
281
|
physical = buffer ? buffer.resolve(path) : path
|
|
255
|
-
|
|
256
|
-
[path, Prism.parse(source, filepath: path).value]
|
|
282
|
+
[path, File.read(physical)]
|
|
257
283
|
rescue StandardError
|
|
258
284
|
[path, nil]
|
|
259
285
|
end
|
|
260
286
|
end
|
|
261
287
|
|
|
288
|
+
def parse_sources(sources)
|
|
289
|
+
sources.to_h do |path, source|
|
|
290
|
+
[path, source.nil? ? nil : Prism.parse(source, filepath: path).value]
|
|
291
|
+
rescue StandardError
|
|
292
|
+
[path, nil]
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def none_mention_registry_method?(sources, registries)
|
|
297
|
+
names = registries.map { |(_plugin_id, registry)| registry.method_name.to_s }
|
|
298
|
+
sources.each_value.none? do |source|
|
|
299
|
+
!source.nil? && names.any? { |name| source.include?(name) }
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
262
303
|
# ADR-16 slice 4 — Concern re-targeting index.
|
|
263
304
|
#
|
|
264
305
|
# Walks every top-level / nested `module M` decl looking for the ActiveSupport::Concern shape:
|
|
@@ -534,6 +575,18 @@ module Rigor
|
|
|
534
575
|
# Returns the Symbol method-name list defined on `module_name`'s RBS instance definition. Empty Array when the
|
|
535
576
|
# module is not in the RBS env (silent skip — the synthetic emit produces nothing rather than fabricating method
|
|
536
577
|
# names).
|
|
578
|
+
#
|
|
579
|
+
# RBS builds a module's instance definition over its self types, or over `Object` when it declares none:
|
|
580
|
+
# `Kernel#puts`, `Object#hash`, the pretty-printer's mixin — and, for `module Devise::Models::X :
|
|
581
|
+
# ActiveRecord::Base`, every `ActiveRecord::Base` method (`save`, `update`) — appear in its method table
|
|
582
|
+
# beside its own members. They are not what including the module adds, and synthesising them would answer
|
|
583
|
+
# the class's own or inherited methods from the module's view of them. A method is kept only when its
|
|
584
|
+
# defining owner is the module itself or one of the module's own ancestors (its `include` / `prepend`
|
|
585
|
+
# chain, which `definition.ancestors` lists without the self types or `Object`).
|
|
586
|
+
#
|
|
587
|
+
# A non-public method is skipped: the tier answers a call by name, and a private module method answered
|
|
588
|
+
# for `user.secret` would silence the call Ruby rejects. A definition that carries no owner (the specs'
|
|
589
|
+
# stubs) keeps every name.
|
|
537
590
|
def module_instance_method_names(module_name, environment)
|
|
538
591
|
return [] if environment.nil?
|
|
539
592
|
|
|
@@ -543,7 +596,14 @@ module Rigor
|
|
|
543
596
|
definition = loader.instance_definition(module_name)
|
|
544
597
|
return [] if definition.nil?
|
|
545
598
|
|
|
546
|
-
|
|
599
|
+
own_ancestors = nil
|
|
600
|
+
definition.methods.filter_map do |name, method|
|
|
601
|
+
next name unless method.respond_to?(:defined_in)
|
|
602
|
+
next unless method.public?
|
|
603
|
+
|
|
604
|
+
own_ancestors ||= definition.ancestors.ancestors.to_set { |ancestor| ancestor.name.to_s }
|
|
605
|
+
name if own_ancestors.include?(method.defined_in.to_s)
|
|
606
|
+
end
|
|
547
607
|
rescue StandardError
|
|
548
608
|
[]
|
|
549
609
|
end
|
|
@@ -98,6 +98,12 @@ module Rigor
|
|
|
98
98
|
@dirty.key?(uri)
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
# @return true when `uri` is open, its bytes are the ones on disk, and the table holds them faithfully: the
|
|
102
|
+
# buffers a whole-project round analysed as they are.
|
|
103
|
+
def on_disk?(uri)
|
|
104
|
+
open?(uri) && !dirty?(uri) && !desynchronized?(uri)
|
|
105
|
+
end
|
|
106
|
+
|
|
101
107
|
def [](uri)
|
|
102
108
|
@entries[uri]
|
|
103
109
|
end
|
|
@@ -100,7 +100,8 @@ module Rigor
|
|
|
100
100
|
publish_batch(eligible)
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
# Runs one whole-project save round and publishes to the publish set (#246)
|
|
103
|
+
# Runs one whole-project save round and publishes to the publish set (#246), then re-publishes each dirty buffer
|
|
104
|
+
# from its own bytes (ADR-124). Called from `didSave`.
|
|
104
105
|
#
|
|
105
106
|
# Analysis scope is the whole project; the PUBLISH SET is the open buffers that are not dirty, plus the
|
|
106
107
|
# buffer that was just saved. A dirty buffer is excluded because only its own `didChange` analysis has
|
|
@@ -117,6 +118,12 @@ module Rigor
|
|
|
117
118
|
end
|
|
118
119
|
end
|
|
119
120
|
|
|
121
|
+
# Re-publishes every open buffer from its own bytes, each through {#publish_for}. Called after the project
|
|
122
|
+
# context is invalidated (a watched file or the configuration changed): every marker on screen was computed
|
|
123
|
+
# against the world that is gone, and since ADR-124 a buffer's markers read other files' declarations, so
|
|
124
|
+
# they can move without the buffer itself changing.
|
|
125
|
+
def republish_open = @buffer_table.uris.each { |uri| publish_for(uri) }
|
|
126
|
+
|
|
120
127
|
# Publishes an EMPTY diagnostic array for `uri`. The LSP-spec idiom for "clear inline markers" — called
|
|
121
128
|
# from `didClose` so clients drop stale highlights when the user closes a buffer.
|
|
122
129
|
def publish_empty(uri)
|
|
@@ -187,16 +194,20 @@ module Rigor
|
|
|
187
194
|
|
|
188
195
|
notify(uri, (by_path[path] || []).filter_map { |diagnostic| to_lsp_diagnostic(diagnostic, path) })
|
|
189
196
|
end
|
|
197
|
+
republish_dirty
|
|
190
198
|
end
|
|
191
199
|
|
|
200
|
+
# The dirty buffers the round may not speak for, re-published from their own bytes (ADR-124). The save that
|
|
201
|
+
# started the round may have moved a declaration a dirty buffer's markers read across files, and nothing
|
|
202
|
+
# else re-analyses that buffer until its next change or save. Each goes through {#publish_for}, so the
|
|
203
|
+
# debouncer and `PublishBatcher` order it against the buffer's own `didChange` publishes: a re-publish run
|
|
204
|
+
# here, beside them, could land its older bytes' markers after a newer edit's.
|
|
205
|
+
def republish_dirty = @buffer_table.uris.each { |uri| publish_for(uri) if @buffer_table.dirty?(uri) }
|
|
206
|
+
|
|
192
207
|
# The URIs this round may speak for: every open buffer whose bytes are the ones on disk. The just-saved
|
|
193
208
|
# URI qualifies by definition (the client wrote it), and is named explicitly so a client that sends
|
|
194
209
|
# `didSave` without a preceding applied `didChange` still refreshes it.
|
|
195
|
-
def publish_set(saved_uri)
|
|
196
|
-
([saved_uri] + @buffer_table.uris).uniq.select do |uri|
|
|
197
|
-
@buffer_table.open?(uri) && !@buffer_table.dirty?(uri) && !@buffer_table.desynchronized?(uri)
|
|
198
|
-
end
|
|
199
|
-
end
|
|
210
|
+
def publish_set(saved_uri) = ([saved_uri] + @buffer_table.uris).uniq.select { |uri| @buffer_table.on_disk?(uri) }
|
|
200
211
|
|
|
201
212
|
def run_and_notify(uri, path)
|
|
202
213
|
entry = @buffer_table[uri]
|
|
@@ -239,11 +250,13 @@ module Rigor
|
|
|
239
250
|
def publish_batch(jobs)
|
|
240
251
|
with_tempfiles(jobs) do |bound_jobs|
|
|
241
252
|
bindings = bound_jobs.map { |job| job.fetch(:binding) }
|
|
253
|
+
@project_context.prepare_discovery_seed
|
|
242
254
|
dispatcher = Analysis::Runner::BufferPoolDispatcher.new(
|
|
243
255
|
configuration: @project_context.configuration,
|
|
244
256
|
cache_store: @project_context.cache_store,
|
|
245
257
|
environment: @project_context.environment,
|
|
246
258
|
prebuilt: @project_context.project_scan,
|
|
259
|
+
discovery_seed_for: @project_context.method(:discovery_seed_for),
|
|
247
260
|
workers: worker_count
|
|
248
261
|
)
|
|
249
262
|
diagnostics_per_binding = dispatcher.analyze(bindings)
|
|
@@ -282,6 +295,7 @@ module Rigor
|
|
|
282
295
|
collect_stats: false,
|
|
283
296
|
buffer: binding,
|
|
284
297
|
prebuilt: @project_context.project_scan,
|
|
298
|
+
discovery_seed: @project_context.discovery_seed_for(binding),
|
|
285
299
|
environment: @project_context.environment
|
|
286
300
|
)
|
|
287
301
|
result = runner.run([path])
|
|
@@ -7,6 +7,8 @@ require_relative "../analysis/runner"
|
|
|
7
7
|
require_relative "../analysis/incremental_session"
|
|
8
8
|
require_relative "../cache/incremental_snapshot"
|
|
9
9
|
require_relative "../effects/identity"
|
|
10
|
+
require_relative "../analysis/path_expansion"
|
|
11
|
+
require_relative "../protection/discovery_seed"
|
|
10
12
|
|
|
11
13
|
module Rigor
|
|
12
14
|
module LanguageServer
|
|
@@ -58,6 +60,7 @@ module Rigor
|
|
|
58
60
|
@primed_key = nil
|
|
59
61
|
@buffer_sources = {}
|
|
60
62
|
@disk_feature_files = nil
|
|
63
|
+
@disk_sig_names = nil
|
|
61
64
|
@environment_features = nil
|
|
62
65
|
@generation = 0
|
|
63
66
|
@environment = nil
|
|
@@ -112,6 +115,31 @@ module Rigor
|
|
|
112
115
|
@project_scan ||= build_project_scan
|
|
113
116
|
end
|
|
114
117
|
|
|
118
|
+
# Issue #2147 — the cross-file discovery tables a per-buffer publish seeds its `Runner` with
|
|
119
|
+
# (`discovery_seed:`, #260). A `prebuilt:` runner runs no discovery pass of its own, so without them every
|
|
120
|
+
# class another file declares reads as an external ancestor and a publish answers differently from
|
|
121
|
+
# `rigor check` over the same tree. The per-file bundles are walked once per generation; each publish
|
|
122
|
+
# re-folds them with `buffer`'s file re-walked from the buffer's bytes
|
|
123
|
+
# ({Protection::DiscoverySeed.tables_for_buffer}), so the buffer's own declarations win over its on-disk
|
|
124
|
+
# ones, and a file whose bytes on disk moved since the bundles were built is re-walked too (its digest no
|
|
125
|
+
# longer matches).
|
|
126
|
+
#
|
|
127
|
+
# The buffer's file is matched by its real path and handed to the fold under the buffer's own spelling: the
|
|
128
|
+
# binding substitutes bytes only for a path spelled exactly as its logical path (the URI's absolute path),
|
|
129
|
+
# while the configured paths may be relative (`lib`, the default) or reach the file through a symlink.
|
|
130
|
+
def discovery_seed_for(buffer)
|
|
131
|
+
Protection::DiscoverySeed.tables_for_buffer(paths: discovery_paths_for(buffer), bundles: discovery_bundles,
|
|
132
|
+
buffer: buffer, constant_sources: true)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Builds what {#discovery_seed_for} reads once per generation, so a fork batch's children inherit it instead
|
|
136
|
+
# of each walking the project.
|
|
137
|
+
def prepare_discovery_seed
|
|
138
|
+
discovery_bundles
|
|
139
|
+
discovery_real_paths
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
115
143
|
# Whole-project diagnostics for one save round (#246). The first call primes an in-process
|
|
116
144
|
# {Analysis::IncrementalSession} — seeded from the on-disk snapshot a terminal
|
|
117
145
|
# `rigor check --incremental` may have left, and otherwise from a full baseline; every later call is a
|
|
@@ -168,9 +196,14 @@ module Rigor
|
|
|
168
196
|
# file.
|
|
169
197
|
def required_features
|
|
170
198
|
disk = (@disk_feature_files ||= Environment::RequiredFeatures.feature_files(@configuration))
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
199
|
+
scanned = disk.reject { |path, _| @buffer_sources.key?(path) }.values
|
|
200
|
+
scanned += @buffer_sources.values.map { |source| Environment::RequiredFeatures.scan_source(source) }
|
|
201
|
+
features = scanned.flat_map(&:first).uniq
|
|
202
|
+
names = scanned.flat_map(&:last).to_set
|
|
203
|
+
names.merge(@disk_sig_names ||= Environment::RequiredFeatures.sig_names(
|
|
204
|
+
Environment::RequiredFeatures.configured_sig_paths(@configuration)
|
|
205
|
+
))
|
|
206
|
+
Environment::RequiredFeatures.relevant(features, names).sort.freeze
|
|
174
207
|
end
|
|
175
208
|
|
|
176
209
|
# Drops every cached collaborator and bumps the generation. The next reader rebuilds from scratch.
|
|
@@ -188,7 +221,11 @@ module Rigor
|
|
|
188
221
|
@environment = nil
|
|
189
222
|
@environment_features = nil
|
|
190
223
|
@disk_feature_files = nil
|
|
224
|
+
@disk_sig_names = nil
|
|
191
225
|
@project_scan = nil
|
|
226
|
+
@discovery_paths = nil
|
|
227
|
+
@discovery_real_paths = nil
|
|
228
|
+
@discovery_bundles = nil
|
|
192
229
|
# The session's per-file cache was computed against the old environment / project scan, so it cannot
|
|
193
230
|
# outlive them. The next save round primes a fresh one.
|
|
194
231
|
@incremental_session = nil
|
|
@@ -230,6 +267,25 @@ module Rigor
|
|
|
230
267
|
)
|
|
231
268
|
end
|
|
232
269
|
|
|
270
|
+
# The configured project files, expanded as `rigor check` expands its configured paths.
|
|
271
|
+
def discovery_paths
|
|
272
|
+
@discovery_paths ||= Analysis::PathExpansion.configured_files(@configuration).freeze
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Every entry that reaches the buffer's file is respelled, so a file configured under two roots (`lib` and a
|
|
276
|
+
# symlink to it) is not folded from disk through the second.
|
|
277
|
+
def discovery_paths_for(buffer)
|
|
278
|
+
Protection::DiscoverySeed.respell_for_buffer(discovery_paths, buffer, real_paths: discovery_real_paths)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def discovery_real_paths
|
|
282
|
+
@discovery_real_paths ||= discovery_paths.map { |path| Rigor::Analysis::BufferBinding.real_path(path) }.freeze
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def discovery_bundles
|
|
286
|
+
@discovery_bundles ||= Protection::DiscoverySeed.bundles(paths: discovery_paths)
|
|
287
|
+
end
|
|
288
|
+
|
|
233
289
|
def build_project_scan
|
|
234
290
|
runner = Analysis::Runner.new(
|
|
235
291
|
configuration: @configuration,
|
|
@@ -282,11 +282,12 @@ module Rigor
|
|
|
282
282
|
end
|
|
283
283
|
|
|
284
284
|
# workspace/didChangeWatchedFiles NOTIFICATION. Invalidates the ProjectContext so cached pre-pass /
|
|
285
|
-
# Environment is rebuilt on the next request
|
|
286
|
-
#
|
|
287
|
-
# follow-up; this is the LSP-correct floor.
|
|
285
|
+
# Environment is rebuilt on the next request, and re-publishes the open buffers against the new world
|
|
286
|
+
# (ADR-124). Slice 7's floor: any watched file change triggers a full context rebuild. Per-file surgical
|
|
287
|
+
# invalidation (per design doc § "Project context refresh") is a follow-up; this is the LSP-correct floor.
|
|
288
288
|
def handle_did_change_watched_files(_params)
|
|
289
289
|
@project_context&.invalidate!
|
|
290
|
+
@publisher&.republish_open
|
|
290
291
|
nil
|
|
291
292
|
end
|
|
292
293
|
|
|
@@ -295,6 +296,7 @@ module Rigor
|
|
|
295
296
|
# (.rigor.yml / Gemfile.lock / etc).
|
|
296
297
|
def handle_did_change_configuration(_params)
|
|
297
298
|
@project_context&.invalidate!
|
|
299
|
+
@publisher&.republish_open
|
|
298
300
|
nil
|
|
299
301
|
end
|
|
300
302
|
|
data/lib/rigor/mcp/server.rb
CHANGED
|
@@ -235,7 +235,7 @@ module Rigor
|
|
|
235
235
|
end
|
|
236
236
|
|
|
237
237
|
def build_argv(name, args) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
|
|
238
|
-
effective_config = args["config"] || @config_path
|
|
238
|
+
effective_config = present_config(args["config"]) || @config_path
|
|
239
239
|
|
|
240
240
|
case name
|
|
241
241
|
when "rigor_check"
|
|
@@ -290,6 +290,14 @@ module Rigor
|
|
|
290
290
|
end
|
|
291
291
|
end
|
|
292
292
|
|
|
293
|
+
# A per-call `config` that is blank names no file, so the session default applies rather than `--config=`
|
|
294
|
+
# reaching the CLI as an empty path (which the CLI refuses).
|
|
295
|
+
def present_config(value)
|
|
296
|
+
return value unless value.is_a?(String) && value.strip.empty?
|
|
297
|
+
|
|
298
|
+
nil
|
|
299
|
+
end
|
|
300
|
+
|
|
293
301
|
def success(id, result)
|
|
294
302
|
{ "jsonrpc" => "2.0", "id" => id, "result" => result }
|
|
295
303
|
end
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "prism"
|
|
|
8
8
|
require_relative "manifest"
|
|
9
9
|
require_relative "node_context"
|
|
10
10
|
require_relative "../analysis/diagnostic"
|
|
11
|
+
require_relative "../analysis/run_disclosure_credit"
|
|
11
12
|
require_relative "../cache/engine_source"
|
|
12
13
|
# `producer generation_cap:` defaults to (and validates against) `Cache::Store::UNBOUNDED_GENERATIONS`, and a
|
|
13
14
|
# plugin class body can be evaluated before anything else pulled the cache layer in.
|
|
@@ -780,11 +781,12 @@ module Rigor
|
|
|
780
781
|
# that differs between workers.
|
|
781
782
|
def disclose_once(key, message:, severity: :info, rule: "load-error")
|
|
782
783
|
id = key.to_s
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
784
|
+
unless run_registration_taken?(id, batch: false)
|
|
785
|
+
@run_disclosures[id] = {
|
|
786
|
+
key: id, message: message.to_s, severity: severity.to_sym, rule: rule.to_s
|
|
787
|
+
}.freeze
|
|
788
|
+
end
|
|
789
|
+
attribute_run_registration(id)
|
|
788
790
|
nil
|
|
789
791
|
end
|
|
790
792
|
|
|
@@ -816,14 +818,15 @@ module Rigor
|
|
|
816
818
|
# raises `ArgumentError`, which the engine reports as this plugin's `runtime-error`.
|
|
817
819
|
def emit_once(key, diagnostics)
|
|
818
820
|
id = key.to_s
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
next row.dup.freeze if row.is_a?(Rigor::Analysis::Diagnostic)
|
|
821
|
+
unless run_registration_taken?(id, batch: true)
|
|
822
|
+
batch = Array(diagnostics).map do |row|
|
|
823
|
+
next row.dup.freeze if row.is_a?(Rigor::Analysis::Diagnostic)
|
|
823
824
|
|
|
824
|
-
|
|
825
|
+
raise ArgumentError, "emit_once expects Rigor::Analysis::Diagnostic rows, got #{row.class}"
|
|
826
|
+
end
|
|
827
|
+
@run_disclosures[id] = { key: id, diagnostics: batch.freeze }.freeze
|
|
825
828
|
end
|
|
826
|
-
|
|
829
|
+
attribute_run_registration(id)
|
|
827
830
|
nil
|
|
828
831
|
end
|
|
829
832
|
|
|
@@ -847,6 +850,18 @@ module Rigor
|
|
|
847
850
|
end
|
|
848
851
|
private :run_registration_taken?
|
|
849
852
|
|
|
853
|
+
# Issue #2020 — hands every call, a repeat of a key already registered included, to the engine's per-file
|
|
854
|
+
# attribution ({Analysis::RunDisclosureCredit}), so an `--incremental` recheck can replay the row for a file
|
|
855
|
+
# it serves from cache. A repeat counts because a discloser memoises the input its notice is about and
|
|
856
|
+
# re-registers the notice from each file's hook. A batch registered empty adds nothing to replay.
|
|
857
|
+
def attribute_run_registration(id)
|
|
858
|
+
record = @run_disclosures[id]
|
|
859
|
+
return if record.nil? || record[:diagnostics]&.empty?
|
|
860
|
+
|
|
861
|
+
Analysis::RunDisclosureCredit.attribute(self, record)
|
|
862
|
+
end
|
|
863
|
+
private :attribute_run_registration
|
|
864
|
+
|
|
850
865
|
# Boilerplate-reduction helper (review §1.3): the "did you mean …?" suggestion every
|
|
851
866
|
# diagnostic-emitting plugin otherwise hand-rolls. Returns the closest of `candidates` to `name` via
|
|
852
867
|
# `DidYouMean::SpellChecker` (the same engine Ruby's own `NoMethodError` hints use), or `nil` when
|