rigortype 0.1.9 → 0.1.10
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/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +73 -3
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +32 -1
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -351,11 +351,6 @@ module Rigor
|
|
|
351
351
|
# descriptor from (1) the plugin's PluginEntry template
|
|
352
352
|
# and (2) the IoBoundary's accumulated FileEntry rows.
|
|
353
353
|
def build_plugin_cache_descriptor
|
|
354
|
-
plugin_entry = Cache::Descriptor::PluginEntry.new(
|
|
355
|
-
id: manifest.id,
|
|
356
|
-
version: manifest.version,
|
|
357
|
-
config_hash: digest_config(config)
|
|
358
|
-
)
|
|
359
354
|
boundary_descriptor = io_boundary.cache_descriptor
|
|
360
355
|
Cache::Descriptor.new(
|
|
361
356
|
plugins: [plugin_entry],
|
|
@@ -363,6 +358,34 @@ module Rigor
|
|
|
363
358
|
)
|
|
364
359
|
end
|
|
365
360
|
|
|
361
|
+
public
|
|
362
|
+
|
|
363
|
+
# ADR-32 WD5 — the `Cache::Descriptor::PluginEntry`
|
|
364
|
+
# template carrying this plugin's id, version, and a
|
|
365
|
+
# SHA-256 digest of its (canonicalised) config hash.
|
|
366
|
+
# Callers outside the plugin (e.g. `Environment.for_project`
|
|
367
|
+
# caching per-file synthesizer output) compose this entry
|
|
368
|
+
# into their own cache descriptor so a config change to
|
|
369
|
+
# the plugin (e.g. flipping `require_magic_comment:`)
|
|
370
|
+
# invalidates the dependent cache.
|
|
371
|
+
def plugin_entry
|
|
372
|
+
# Built fresh on each call rather than memoised so a
|
|
373
|
+
# plugin subclass that freezes itself in `initialize`
|
|
374
|
+
# (e.g. `Rigor::Plugin::RbsInline` per ADR-32) doesn't
|
|
375
|
+
# trip a FrozenError on first read. The construction
|
|
376
|
+
# cost is a single `Data.define`-backed value-object
|
|
377
|
+
# build; the cache key derivation downstream is the
|
|
378
|
+
# expensive step, and it's already memoised inside
|
|
379
|
+
# `Cache::Store`.
|
|
380
|
+
Cache::Descriptor::PluginEntry.new(
|
|
381
|
+
id: manifest.id,
|
|
382
|
+
version: manifest.version,
|
|
383
|
+
config_hash: digest_config(config)
|
|
384
|
+
)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
private
|
|
388
|
+
|
|
366
389
|
# ADR-7 § "Slice 6" follow-up — composes the auto-built
|
|
367
390
|
# cache descriptor with an optional plugin-author-supplied
|
|
368
391
|
# extension. Extra `GemEntry` / `FileEntry` / `ConfigEntry`
|
|
@@ -43,14 +43,15 @@ module Rigor
|
|
|
43
43
|
attr_reader :id, :version, :description, :protocols, :config_schema, :produces, :consumes,
|
|
44
44
|
:owns_receivers, :open_receivers, :type_node_resolvers, :block_as_methods,
|
|
45
45
|
:heredoc_templates, :trait_registries, :external_files, :hkt_registrations,
|
|
46
|
-
:hkt_definitions, :signature_paths, :protocol_contracts
|
|
46
|
+
:hkt_definitions, :signature_paths, :protocol_contracts, :source_rbs_synthesizer
|
|
47
47
|
|
|
48
48
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
49
49
|
id:, version:,
|
|
50
50
|
description: nil, protocols: [], config_schema: {},
|
|
51
51
|
produces: [], consumes: [], owns_receivers: [], open_receivers: [], type_node_resolvers: [],
|
|
52
52
|
block_as_methods: [], heredoc_templates: [], trait_registries: [], external_files: [],
|
|
53
|
-
hkt_registrations: [], hkt_definitions: [], signature_paths: [], protocol_contracts: []
|
|
53
|
+
hkt_registrations: [], hkt_definitions: [], signature_paths: [], protocol_contracts: [],
|
|
54
|
+
source_rbs_synthesizer: nil
|
|
54
55
|
)
|
|
55
56
|
validate_id!(id)
|
|
56
57
|
validate_version!(version)
|
|
@@ -68,10 +69,12 @@ module Rigor
|
|
|
68
69
|
validate_hkt_definitions!(hkt_definitions)
|
|
69
70
|
validate_signature_paths!(signature_paths)
|
|
70
71
|
validate_protocol_contracts!(protocol_contracts)
|
|
72
|
+
validate_source_rbs_synthesizer!(source_rbs_synthesizer)
|
|
71
73
|
|
|
72
74
|
assign_fields(id, version, description, protocols, config_schema, produces, consumes, owns_receivers,
|
|
73
75
|
open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
|
|
74
|
-
external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts
|
|
76
|
+
external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
|
|
77
|
+
source_rbs_synthesizer)
|
|
75
78
|
freeze
|
|
76
79
|
end
|
|
77
80
|
|
|
@@ -80,7 +83,8 @@ module Rigor
|
|
|
80
83
|
# rubocop:disable Metrics/ParameterLists, Metrics/AbcSize
|
|
81
84
|
def assign_fields(id, version, description, protocols, config_schema, produces, consumes, owns_receivers,
|
|
82
85
|
open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
|
|
83
|
-
external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts
|
|
86
|
+
external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
|
|
87
|
+
source_rbs_synthesizer)
|
|
84
88
|
@id = id.dup.freeze
|
|
85
89
|
@version = version.dup.freeze
|
|
86
90
|
@description = description.nil? ? nil : description.to_s.dup.freeze
|
|
@@ -99,6 +103,7 @@ module Rigor
|
|
|
99
103
|
@hkt_definitions = hkt_definitions.dup.freeze
|
|
100
104
|
@signature_paths = signature_paths.map { |p| p.to_s.dup.freeze }.freeze
|
|
101
105
|
@protocol_contracts = protocol_contracts.dup.freeze
|
|
106
|
+
@source_rbs_synthesizer = source_rbs_synthesizer
|
|
102
107
|
end
|
|
103
108
|
# rubocop:enable Metrics/ParameterLists, Metrics/AbcSize
|
|
104
109
|
|
|
@@ -146,7 +151,8 @@ module Rigor
|
|
|
146
151
|
"hkt_registrations" => hkt_registrations.map(&:to_h),
|
|
147
152
|
"hkt_definitions" => hkt_definitions.map { |d| { "uri" => d.uri, "params" => d.params } },
|
|
148
153
|
"signature_paths" => signature_paths,
|
|
149
|
-
"protocol_contracts" => protocol_contracts.map(&:to_h)
|
|
154
|
+
"protocol_contracts" => protocol_contracts.map(&:to_h),
|
|
155
|
+
"source_rbs_synthesizer" => source_rbs_synthesizer&.class&.name
|
|
150
156
|
}
|
|
151
157
|
end
|
|
152
158
|
|
|
@@ -389,6 +395,28 @@ module Rigor
|
|
|
389
395
|
"Rigor::Plugin::ProtocolContract instances, got #{entries.inspect}"
|
|
390
396
|
end
|
|
391
397
|
|
|
398
|
+
# ADR-32 WD4 — `source_rbs_synthesizer:` declares a callable
|
|
399
|
+
# the engine invokes once per analysed Ruby source file at
|
|
400
|
+
# env-build time. The callable receives a source file path
|
|
401
|
+
# (String) and returns either an RBS source String to merge
|
|
402
|
+
# into the analysis environment or `nil` (no contribution
|
|
403
|
+
# for this file). Distinct from `signature_paths:` (static,
|
|
404
|
+
# bundled RBS): the synthesizer derives RBS from project
|
|
405
|
+
# source on each run.
|
|
406
|
+
#
|
|
407
|
+
# The value is held as-given (no dup / freeze) because a
|
|
408
|
+
# callable instance is opaque to the manifest; the plugin
|
|
409
|
+
# author is responsible for thread-/Ractor-safety of any
|
|
410
|
+
# captured state (per ADR-15).
|
|
411
|
+
def validate_source_rbs_synthesizer!(synthesizer)
|
|
412
|
+
return if synthesizer.nil?
|
|
413
|
+
return if synthesizer.respond_to?(:call)
|
|
414
|
+
|
|
415
|
+
raise ArgumentError,
|
|
416
|
+
"plugin manifest source_rbs_synthesizer must respond to :call, " \
|
|
417
|
+
"got #{synthesizer.inspect}"
|
|
418
|
+
end
|
|
419
|
+
|
|
392
420
|
def coerce_consumes(consumes)
|
|
393
421
|
unless consumes.is_a?(Array)
|
|
394
422
|
raise ArgumentError, "plugin manifest consumes must be an Array, got #{consumes.inspect}"
|
|
@@ -160,6 +160,27 @@ module Rigor
|
|
|
160
160
|
protocol_contracts.select { |contract| path_matches_glob?(contract.path_glob, path_s) }
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
+
# ADR-32 WD4 + WD5 — flat ordered list of
|
|
164
|
+
# `[plugin, callable]` pairs for every loaded plugin that
|
|
165
|
+
# declares a `source_rbs_synthesizer:` in its manifest. The
|
|
166
|
+
# engine invokes each callable once per analysed Ruby source
|
|
167
|
+
# file at env-build time; non-nil return strings are merged
|
|
168
|
+
# into the RBS environment as virtual signature sources.
|
|
169
|
+
# The full plugin instance is carried alongside the
|
|
170
|
+
# callable so the engine's cache layer (WD5) can compose
|
|
171
|
+
# `plugin.plugin_entry` into its per-file descriptor — a
|
|
172
|
+
# config change to the plugin (e.g. flipping
|
|
173
|
+
# `require_magic_comment:`) invalidates the dependent
|
|
174
|
+
# synthesizer cache without any plugin-side bookkeeping.
|
|
175
|
+
def source_rbs_synthesizers
|
|
176
|
+
plugins.filter_map do |plugin|
|
|
177
|
+
synthesizer = plugin.manifest.source_rbs_synthesizer
|
|
178
|
+
next nil if synthesizer.nil?
|
|
179
|
+
|
|
180
|
+
[plugin, synthesizer]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
163
184
|
FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
164
185
|
private_constant :FNMATCH_FLAGS
|
|
165
186
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# ADR-32 WD6 — per-run accumulator for failures encountered by
|
|
6
|
+
# a plugin's `Manifest#source_rbs_synthesizer` callable. The
|
|
7
|
+
# synthesizer returns `[:error, message]` on parse failure
|
|
8
|
+
# (per its contract); `Environment.for_project` routes the
|
|
9
|
+
# tuple through `#record` here. `Analysis::Runner` queries
|
|
10
|
+
# `#entries` after analysis and emits one
|
|
11
|
+
# `source-rbs-synthesis-failed` `:info` diagnostic per
|
|
12
|
+
# entry so the user sees which files contributed nothing
|
|
13
|
+
# and why.
|
|
14
|
+
#
|
|
15
|
+
# Empty by default. The Runner only emits diagnostics when
|
|
16
|
+
# at least one entry is recorded — projects without
|
|
17
|
+
# synthesizer-emitting plugins pay zero cost.
|
|
18
|
+
#
|
|
19
|
+
# Thread-/Ractor-safety: this reporter is per-`WorkerSession`
|
|
20
|
+
# in pool mode, so concurrent writes from one Ractor's
|
|
21
|
+
# `collect_virtual_rbs` loop are serialised by the worker
|
|
22
|
+
# body itself. The sequential path shares a single reporter
|
|
23
|
+
# across the run; entries are appended one at a time during
|
|
24
|
+
# env build (before any per-file analysis runs), so no
|
|
25
|
+
# locking is needed.
|
|
26
|
+
class SourceRbsSynthesisReporter
|
|
27
|
+
Entry = Data.define(:plugin_id, :path, :message)
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@entries = []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def record(plugin_id:, path:, message:)
|
|
34
|
+
@entries << Entry.new(
|
|
35
|
+
plugin_id: plugin_id.to_s.dup.freeze,
|
|
36
|
+
path: path.to_s.dup.freeze,
|
|
37
|
+
message: message.to_s.dup.freeze
|
|
38
|
+
)
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def entries
|
|
43
|
+
@entries.dup.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def empty?
|
|
47
|
+
@entries.empty?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -7,6 +7,7 @@ require_relative "../environment"
|
|
|
7
7
|
require_relative "../scope"
|
|
8
8
|
require_relative "../reflection"
|
|
9
9
|
require_relative "../type"
|
|
10
|
+
require_relative "../inference/def_return_typer"
|
|
10
11
|
require_relative "../inference/scope_indexer"
|
|
11
12
|
require_relative "../inference/rbs_type_translator"
|
|
12
13
|
|
|
@@ -118,7 +119,8 @@ module Rigor
|
|
|
118
119
|
candidates_from_defs = defs.filter_map do |def_node, class_name, kind|
|
|
119
120
|
classify_def(path, def_node, class_name, kind, scope_index)
|
|
120
121
|
end
|
|
121
|
-
|
|
122
|
+
obs_ivar_map = build_observed_ivar_map(parse_result.value)
|
|
123
|
+
candidates_from_defs + collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map)
|
|
122
124
|
end
|
|
123
125
|
|
|
124
126
|
# Walks the AST collecting `(def_node, class_name, kind)`
|
|
@@ -501,73 +503,11 @@ module Rigor
|
|
|
501
503
|
# `V | nil` via `return nil unless ...`). The walk
|
|
502
504
|
# excludes nested `DefNode` / lambda / block scopes
|
|
503
505
|
# whose returns belong to different methods.
|
|
506
|
+
# Delegates to {Rigor::Inference::DefReturnTyper} — the same
|
|
507
|
+
# body-typing + explicit-return-union the `rigor annotate`
|
|
508
|
+
# def-line annotator uses.
|
|
504
509
|
def infer_return_type(def_node, scope_index)
|
|
505
|
-
|
|
506
|
-
return nil if body.nil?
|
|
507
|
-
|
|
508
|
-
last = body_last_expression(body)
|
|
509
|
-
return nil if last.nil?
|
|
510
|
-
|
|
511
|
-
inner_scope = scope_index[last] || scope_index[body] || scope_index[def_node]
|
|
512
|
-
return nil if inner_scope.nil?
|
|
513
|
-
|
|
514
|
-
last_type = inner_scope.type_of(last)
|
|
515
|
-
union_with_explicit_returns(body, last_type, scope_index)
|
|
516
|
-
rescue StandardError
|
|
517
|
-
nil
|
|
518
|
-
end
|
|
519
|
-
|
|
520
|
-
def body_last_expression(body)
|
|
521
|
-
case body
|
|
522
|
-
when Prism::StatementsNode then body.body.last
|
|
523
|
-
when Prism::BeginNode then body_last_expression(body.statements)
|
|
524
|
-
else body
|
|
525
|
-
end
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
def union_with_explicit_returns(body, last_type, scope_index)
|
|
529
|
-
return_types = []
|
|
530
|
-
collect_return_types(body, scope_index, return_types)
|
|
531
|
-
return last_type if return_types.empty?
|
|
532
|
-
|
|
533
|
-
Type::Combinator.union(last_type, *return_types)
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
RETURN_BARRIER_NODES = [Prism::DefNode, Prism::LambdaNode, Prism::BlockNode].freeze
|
|
537
|
-
private_constant :RETURN_BARRIER_NODES
|
|
538
|
-
|
|
539
|
-
def collect_return_types(node, scope_index, out)
|
|
540
|
-
return unless node.is_a?(Prism::Node)
|
|
541
|
-
return if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
542
|
-
|
|
543
|
-
type_return_node(node, scope_index, out) if node.is_a?(Prism::ReturnNode)
|
|
544
|
-
node.compact_child_nodes.each { |c| collect_return_types(c, scope_index, out) }
|
|
545
|
-
end
|
|
546
|
-
|
|
547
|
-
def type_return_node(return_node, scope_index, out)
|
|
548
|
-
args = return_node.arguments&.arguments || []
|
|
549
|
-
if args.empty?
|
|
550
|
-
out << Type::Combinator.constant_of(nil)
|
|
551
|
-
return
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
scope = scope_index[return_node] || scope_index[args.first]
|
|
555
|
-
return if scope.nil?
|
|
556
|
-
|
|
557
|
-
# `return a, b` packs into a Tuple at runtime; the MVP
|
|
558
|
-
# only handles the single-value form. Multi-arg returns
|
|
559
|
-
# contribute no type to keep the implementation
|
|
560
|
-
# focused.
|
|
561
|
-
return unless args.size == 1
|
|
562
|
-
|
|
563
|
-
type = safe_return_type_of(scope, args.first)
|
|
564
|
-
out << type unless type.nil?
|
|
565
|
-
end
|
|
566
|
-
|
|
567
|
-
def safe_return_type_of(scope, node)
|
|
568
|
-
scope.type_of(node)
|
|
569
|
-
rescue StandardError
|
|
570
|
-
nil
|
|
510
|
+
Inference::DefReturnTyper.call(def_node, scope_index)
|
|
571
511
|
end
|
|
572
512
|
|
|
573
513
|
def dynamic_top?(type)
|
|
@@ -746,7 +686,7 @@ module Rigor
|
|
|
746
686
|
def computed_literal_tightening?(inferred, def_node)
|
|
747
687
|
return false unless inferred.is_a?(Type::Constant)
|
|
748
688
|
|
|
749
|
-
last = body_last_expression(def_node.body)
|
|
689
|
+
last = Inference::DefReturnTyper.body_last_expression(def_node.body)
|
|
750
690
|
!direct_literal_node?(last)
|
|
751
691
|
end
|
|
752
692
|
|
|
@@ -898,11 +838,15 @@ module Rigor
|
|
|
898
838
|
|
|
899
839
|
# Per-file context the attr_* walker threads through its
|
|
900
840
|
# recursive descent. Keeps parameter lists in check.
|
|
901
|
-
|
|
841
|
+
# `obs_ivar_map` carries the observation-derived fallback types
|
|
842
|
+
# built by {#build_observed_ivar_map}; it is empty when sig-gen
|
|
843
|
+
# is invoked without `--params=observed`.
|
|
844
|
+
AttrWalkContext = Struct.new(:path, :scope_index, :obs_ivar_map, :out, keyword_init: true)
|
|
902
845
|
private_constant :AttrWalkContext
|
|
903
846
|
|
|
904
|
-
def collect_attr_candidates(root, path, scope_index)
|
|
905
|
-
ctx = AttrWalkContext.new(path: path, scope_index: scope_index,
|
|
847
|
+
def collect_attr_candidates(root, path, scope_index, obs_ivar_map = {})
|
|
848
|
+
ctx = AttrWalkContext.new(path: path, scope_index: scope_index,
|
|
849
|
+
obs_ivar_map: obs_ivar_map, out: [])
|
|
906
850
|
walk_attr_calls(root, [], false, ctx)
|
|
907
851
|
ctx.out
|
|
908
852
|
end
|
|
@@ -941,7 +885,7 @@ module Rigor
|
|
|
941
885
|
symbol_names = extract_symbol_arguments(call_node)
|
|
942
886
|
return if symbol_names.empty?
|
|
943
887
|
|
|
944
|
-
ivar_lookup = ivar_type_lookup(ctx.scope_index, class_name)
|
|
888
|
+
ivar_lookup = ivar_type_lookup(ctx.scope_index, class_name, ctx.obs_ivar_map)
|
|
945
889
|
symbol_names.each do |attr_name|
|
|
946
890
|
ivar_type = ivar_lookup.call(attr_name)
|
|
947
891
|
ctx.out.concat(build_attr_candidates(call_node.name, class_name, attr_name, ivar_type, ctx))
|
|
@@ -961,12 +905,143 @@ module Rigor
|
|
|
961
905
|
# before any statement evaluation runs, so the lookup
|
|
962
906
|
# works even when attr_* declarations come before the
|
|
963
907
|
# corresponding ivar writes lexically.
|
|
964
|
-
|
|
908
|
+
#
|
|
909
|
+
# When `obs_ivar_map` is non-empty (i.e. `--params=observed`
|
|
910
|
+
# was used), it acts as a fallback: if the ivar pre-pass
|
|
911
|
+
# resolved the type to `nil` or `Dynamic[top]` — typically
|
|
912
|
+
# because `@ivar = param` inside `initialize` typed the param
|
|
913
|
+
# as `untyped` — the observation-derived type is substituted.
|
|
914
|
+
# This lets `attr_reader :name` emit a concrete type when
|
|
915
|
+
# `ClassName.new("alice")` call sites are visible to the
|
|
916
|
+
# observation scan.
|
|
917
|
+
def ivar_type_lookup(scope_index, class_name, obs_ivar_map = {})
|
|
965
918
|
any_scope = scope_index.each_value.first
|
|
966
919
|
return ->(_) {} if any_scope.nil?
|
|
967
920
|
|
|
968
|
-
ivars
|
|
969
|
-
|
|
921
|
+
ivars = any_scope.class_ivars_for(class_name)
|
|
922
|
+
obs_ivars = obs_ivar_map[class_name] || {}
|
|
923
|
+
lambda do |attr_name|
|
|
924
|
+
type = ivars[:"@#{attr_name}"]
|
|
925
|
+
type.nil? || dynamic_top?(type) ? obs_ivars[attr_name] : type
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# Build a { class_name => { attr_name_sym => Type } } map that
|
|
930
|
+
# records observation-derived types for ivars assigned directly
|
|
931
|
+
# from `def initialize` parameters. Only populated when
|
|
932
|
+
# `@observations` is non-empty (i.e. `--params=observed` was
|
|
933
|
+
# supplied). Matches the pattern `@ivar_name = param_name` where
|
|
934
|
+
# `param_name` is a required / optional positional or keyword
|
|
935
|
+
# parameter of `initialize`.
|
|
936
|
+
def build_observed_ivar_map(root)
|
|
937
|
+
return {} if @observations.empty?
|
|
938
|
+
|
|
939
|
+
result = {}
|
|
940
|
+
collect_init_ivar_obs(root, [], result)
|
|
941
|
+
result
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def collect_init_ivar_obs(node, prefix, result)
|
|
945
|
+
return unless node.is_a?(Prism::Node)
|
|
946
|
+
|
|
947
|
+
case node
|
|
948
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
949
|
+
name = qualified_constant_path(node.constant_path)
|
|
950
|
+
if name
|
|
951
|
+
collect_init_ivar_obs(node.body, prefix + [name], result) if node.body
|
|
952
|
+
return
|
|
953
|
+
end
|
|
954
|
+
when Prism::DefNode
|
|
955
|
+
if node.name == :initialize && !prefix.empty?
|
|
956
|
+
class_name = prefix.join("::")
|
|
957
|
+
map = ivar_obs_from_initialize(class_name, node)
|
|
958
|
+
result[class_name] = (result[class_name] || {}).merge(map) unless map.empty?
|
|
959
|
+
end
|
|
960
|
+
return
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
node.compact_child_nodes.each { |c| collect_init_ivar_obs(c, prefix, result) }
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
# Derive { attr_name_sym => Type } for a single `def initialize`
|
|
967
|
+
# by matching `@ivar = param_name` assignments against the
|
|
968
|
+
# available `[class_name, :initialize]` observations.
|
|
969
|
+
def ivar_obs_from_initialize(class_name, def_node)
|
|
970
|
+
obs_list = @observations[[class_name, :initialize]]
|
|
971
|
+
return {} if obs_list.nil? || obs_list.empty?
|
|
972
|
+
return {} if def_node.body.nil? || def_node.parameters.nil?
|
|
973
|
+
|
|
974
|
+
param_index = build_init_param_index(def_node.parameters)
|
|
975
|
+
return {} if param_index.empty?
|
|
976
|
+
|
|
977
|
+
ivar_to_param = {}
|
|
978
|
+
scan_ivar_param_assignments(def_node.body, param_index.keys.to_set, ivar_to_param)
|
|
979
|
+
build_ivar_obs_type_map(ivar_to_param, param_index, obs_list)
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
# Map `{ ivar_name => param_name }` → `{ attr_name_sym => Type }`
|
|
983
|
+
# by looking up each param's observation types and unioning them.
|
|
984
|
+
def build_ivar_obs_type_map(ivar_to_param, param_index, obs_list)
|
|
985
|
+
ivar_to_param.filter_map do |ivar_name, param_name|
|
|
986
|
+
types = collect_param_obs_types(obs_list, param_name, param_index[param_name])
|
|
987
|
+
next if types.empty?
|
|
988
|
+
|
|
989
|
+
attr_name = ivar_name.to_s.delete_prefix("@").to_sym
|
|
990
|
+
[attr_name, types.reduce { |acc, t| Type::Combinator.union(acc, t) }]
|
|
991
|
+
end.to_h
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# Collect observed argument types for a single parameter across all
|
|
995
|
+
# call-site observations. Returns an array of Type objects (may be empty).
|
|
996
|
+
def collect_param_obs_types(obs_list, param_name, param_info)
|
|
997
|
+
case param_info[:kind]
|
|
998
|
+
when :positional then obs_list.filter_map { |obs| obs.positional[param_info[:index]] }
|
|
999
|
+
when :keyword then obs_list.filter_map { |obs| obs.keyword[param_name] }
|
|
1000
|
+
else []
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# Map param_name_sym → { kind: :positional, index: N } or
|
|
1005
|
+
# { kind: :keyword } for required / optional positionals and
|
|
1006
|
+
# required / optional keywords of a ParametersNode.
|
|
1007
|
+
def build_init_param_index(parameters)
|
|
1008
|
+
index = {}
|
|
1009
|
+
offset = 0
|
|
1010
|
+
|
|
1011
|
+
(parameters.requireds || []).each_with_index do |p, i|
|
|
1012
|
+
index[p.name] = { kind: :positional, index: offset + i } if p.respond_to?(:name)
|
|
1013
|
+
end
|
|
1014
|
+
offset += parameters.requireds&.size || 0
|
|
1015
|
+
|
|
1016
|
+
(parameters.optionals || []).each_with_index do |p, i|
|
|
1017
|
+
index[p.name] = { kind: :positional, index: offset + i } if p.respond_to?(:name)
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
(parameters.keywords || []).each do |kw|
|
|
1021
|
+
index[kw.name] = { kind: :keyword } if kw.respond_to?(:name)
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
index
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# Walk a def body for direct `@ivar = local_var` assignments
|
|
1028
|
+
# where `local_var` is one of the listed parameter names.
|
|
1029
|
+
# Records ivar_name (Symbol with `@` prefix) → param_name.
|
|
1030
|
+
# Does not recurse into nested defs / classes / modules.
|
|
1031
|
+
def scan_ivar_param_assignments(node, param_names, result)
|
|
1032
|
+
return unless node.is_a?(Prism::Node)
|
|
1033
|
+
|
|
1034
|
+
if node.is_a?(Prism::InstanceVariableWriteNode) &&
|
|
1035
|
+
node.value.is_a?(Prism::LocalVariableReadNode) &&
|
|
1036
|
+
param_names.include?(node.value.name)
|
|
1037
|
+
result[node.name] ||= node.value.name
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
return if node.is_a?(Prism::DefNode) ||
|
|
1041
|
+
node.is_a?(Prism::ClassNode) ||
|
|
1042
|
+
node.is_a?(Prism::ModuleNode)
|
|
1043
|
+
|
|
1044
|
+
node.compact_child_nodes.each { |c| scan_ivar_param_assignments(c, param_names, result) }
|
|
970
1045
|
end
|
|
971
1046
|
|
|
972
1047
|
def build_attr_candidates(call_name, class_name, attr_name, ivar_type, ctx)
|
|
@@ -256,6 +256,63 @@ module Rigor
|
|
|
256
256
|
refined.base.class_name == "String"
|
|
257
257
|
end
|
|
258
258
|
|
|
259
|
+
# Returns true when `type` is statically known to be a
|
|
260
|
+
# non-empty String — i.e. its value can never be `""`.
|
|
261
|
+
# Used at String binary-operator dispatch sites to propagate
|
|
262
|
+
# the non-empty guarantee through `+` and `*`.
|
|
263
|
+
#
|
|
264
|
+
# - `Constant[s]` where `s != ""` — a concrete non-empty literal.
|
|
265
|
+
# - `Difference[Nominal[String], Constant[""]]` — the canonical
|
|
266
|
+
# `non-empty-string` carrier.
|
|
267
|
+
# - `Intersection[…]` — any member suffices (set-theoretic subset).
|
|
268
|
+
# - `Union[…]` — all members must qualify (the join may include "").
|
|
269
|
+
def non_empty_string_compatible?(type)
|
|
270
|
+
case type
|
|
271
|
+
when Constant then type.value.is_a?(String) && !type.value.empty?
|
|
272
|
+
when Difference then non_empty_string_difference?(type)
|
|
273
|
+
when Intersection then type.members.any? { |m| non_empty_string_compatible?(m) }
|
|
274
|
+
when Union then !type.members.empty? && type.members.all? { |m| non_empty_string_compatible?(m) }
|
|
275
|
+
else false
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def non_empty_string_difference?(diff)
|
|
280
|
+
return false unless diff.base.is_a?(Nominal) && diff.base.class_name == "String"
|
|
281
|
+
return false unless diff.removed.is_a?(Constant)
|
|
282
|
+
|
|
283
|
+
diff.removed.value == ""
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Returns true when `type` is statically known to be a
|
|
287
|
+
# non-zero Integer — i.e. its value can never be `0`.
|
|
288
|
+
# Used at Integer arithmetic dispatch sites to propagate
|
|
289
|
+
# the non-zero guarantee through `*` and identity methods.
|
|
290
|
+
#
|
|
291
|
+
# - `Constant[n]` where `n != 0` — a concrete non-zero literal.
|
|
292
|
+
# - `Difference[Nominal[Integer], Constant[0]]` — the canonical
|
|
293
|
+
# `non-zero-int` carrier.
|
|
294
|
+
# - `IntegerRange` that does not cover 0 — both `positive-int`
|
|
295
|
+
# ([1,+∞)) and `negative-int` ([-∞,-1]) qualify.
|
|
296
|
+
# - `Intersection[…]` — any member suffices.
|
|
297
|
+
# - `Union[…]` — all members must qualify.
|
|
298
|
+
def non_zero_int_compatible?(type)
|
|
299
|
+
case type
|
|
300
|
+
when Constant then type.value.is_a?(Integer) && !type.value.zero?
|
|
301
|
+
when Difference then non_zero_int_difference?(type)
|
|
302
|
+
when IntegerRange then !type.covers?(0)
|
|
303
|
+
when Intersection then type.members.any? { |m| non_zero_int_compatible?(m) }
|
|
304
|
+
when Union then !type.members.empty? && type.members.all? { |m| non_zero_int_compatible?(m) }
|
|
305
|
+
else false
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def non_zero_int_difference?(diff)
|
|
310
|
+
return false unless diff.base.is_a?(Nominal) && diff.base.class_name == "Integer"
|
|
311
|
+
return false unless diff.removed.is_a?(Constant)
|
|
312
|
+
|
|
313
|
+
diff.removed.value.zero?
|
|
314
|
+
end
|
|
315
|
+
|
|
259
316
|
# Normalised intersection. Flattens nested Intersections,
|
|
260
317
|
# drops `Top` members, collapses to `Bot` if any member is
|
|
261
318
|
# `Bot`, deduplicates structurally-equal members, sorts the
|
data/lib/rigor/version.rb
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Rigor
|
|
2
|
+
module Analysis
|
|
3
|
+
# ADR-22 Slice 1 — per-project baseline filter.
|
|
4
|
+
# See lib/rigor/analysis/baseline.rb for the full contract.
|
|
5
|
+
class Baseline
|
|
6
|
+
class LoadError < ::StandardError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Load a baseline file from disk.
|
|
10
|
+
# Returns `nil` when `path` is nil (no baseline configured).
|
|
11
|
+
# Raises {LoadError} on malformed or unsupported content.
|
|
12
|
+
def self.load: (::String? path) -> Baseline?
|
|
13
|
+
|
|
14
|
+
# Build a baseline from a current run's diagnostic stream.
|
|
15
|
+
def self.from_diagnostics: (untyped diagnostics, ?match_mode: :rule | :message) -> Baseline
|
|
16
|
+
|
|
17
|
+
attr_reader buckets: untyped
|
|
18
|
+
|
|
19
|
+
def initialize: (untyped buckets) -> void
|
|
20
|
+
|
|
21
|
+
# Apply the baseline filter. Returns [surfaced_diagnostics, silenced_count].
|
|
22
|
+
def filter: (untyped diagnostics) -> [untyped, ::Integer]
|
|
23
|
+
|
|
24
|
+
# Report bucket-level drift for each recorded baseline bucket.
|
|
25
|
+
def audit: (untyped diagnostics) -> untyped
|
|
26
|
+
|
|
27
|
+
# Return a new Baseline with the given buckets removed.
|
|
28
|
+
def without: (untyped buckets_to_drop) -> Baseline
|
|
29
|
+
|
|
30
|
+
# Serialise to a YAML string.
|
|
31
|
+
def to_yaml: () -> ::String
|
|
32
|
+
|
|
33
|
+
# Number of recorded buckets.
|
|
34
|
+
def size: () -> ::Integer
|
|
35
|
+
|
|
36
|
+
def empty?: () -> bool
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/sig/rigor/environment.rbs
CHANGED
|
@@ -15,11 +15,12 @@ module Rigor
|
|
|
15
15
|
attr_reader hkt_registry: untyped?
|
|
16
16
|
|
|
17
17
|
def self.default: () -> Environment
|
|
18
|
-
def self.for_project: (?root: String, ?libraries: Array[String], ?signature_paths: Array[String | _ToPath]?, ?cache_store: untyped?, ?plugin_registry: untyped?, ?dependency_source_index: untyped?, ?rbs_extended_reporter: untyped?, ?boundary_cross_reporter: untyped?, ?bundler_bundle_path: String?, ?bundler_auto_detect: bool, ?synthetic_method_index: untyped?, ?project_patched_methods: untyped?) -> Environment
|
|
18
|
+
def self.for_project: (?root: String, ?libraries: Array[String], ?signature_paths: Array[String | _ToPath]?, ?cache_store: untyped?, ?plugin_registry: untyped?, ?dependency_source_index: untyped?, ?rbs_extended_reporter: untyped?, ?boundary_cross_reporter: untyped?, ?source_rbs_synthesis_reporter: untyped?, ?bundler_bundle_path: String?, ?bundler_auto_detect: bool, ?synthetic_method_index: untyped?, ?project_patched_methods: untyped?) -> Environment
|
|
19
19
|
|
|
20
|
-
def initialize: (?class_registry: ClassRegistry, ?rbs_loader: RbsLoader?, ?plugin_registry: untyped?, ?dependency_source_index: untyped?, ?rbs_extended_reporter: untyped?, ?boundary_cross_reporter: untyped?, ?synthetic_method_index: untyped?, ?project_patched_methods: untyped?) -> void
|
|
20
|
+
def initialize: (?class_registry: ClassRegistry, ?rbs_loader: RbsLoader?, ?plugin_registry: untyped?, ?dependency_source_index: untyped?, ?rbs_extended_reporter: untyped?, ?boundary_cross_reporter: untyped?, ?source_rbs_synthesis_reporter: untyped?, ?synthetic_method_index: untyped?, ?project_patched_methods: untyped?) -> void
|
|
21
21
|
def rbs_extended_reporter: () -> untyped?
|
|
22
22
|
def boundary_cross_reporter: () -> untyped?
|
|
23
|
+
def source_rbs_synthesis_reporter: () -> untyped?
|
|
23
24
|
def attach_reporters!: (rbs_extended_reporter: untyped?, boundary_cross_reporter: untyped?) -> nil
|
|
24
25
|
def nominal_for_name: (String | Symbol name) -> Type::Nominal?
|
|
25
26
|
def singleton_for_name: (String | Symbol name) -> Type::Singleton?
|
data/sig/rigor/type.rbs
CHANGED
|
@@ -266,6 +266,10 @@ module Rigor
|
|
|
266
266
|
def self?.non_numeric_string: () -> Refined
|
|
267
267
|
def self?.literal_string_carrier?: (Type::t refined) -> bool
|
|
268
268
|
def self?.literal_string_compatible?: (Type::t type) -> bool
|
|
269
|
+
def self?.non_empty_string_compatible?: (Type::t type) -> bool
|
|
270
|
+
def self?.non_empty_string_difference?: (Type::t diff) -> bool
|
|
271
|
+
def self?.non_zero_int_compatible?: (Type::t type) -> bool
|
|
272
|
+
def self?.non_zero_int_difference?: (Type::t diff) -> bool
|
|
269
273
|
def self?.intersection: (*Type::t members) -> Type::t
|
|
270
274
|
def self?.non_empty_lowercase_string: () -> Type::t
|
|
271
275
|
def self?.non_empty_uppercase_string: () -> Type::t
|
data/sig/rigor.rbs
CHANGED
|
@@ -12,6 +12,8 @@ module Rigor
|
|
|
12
12
|
attr_reader baseline_path: String?
|
|
13
13
|
|
|
14
14
|
def self.load: (?String path) -> Configuration
|
|
15
|
+
def self.discover: () -> String?
|
|
16
|
+
def self.load_with_includes: (String path, ?visited: Set[String]) -> Hash[String, untyped]
|
|
15
17
|
def initialize: (?Hash[String, untyped] data) -> void
|
|
16
18
|
def to_h: () -> Hash[String, untyped]
|
|
17
19
|
end
|