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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/runner.rb +67 -9
  4. data/lib/rigor/analysis/worker_session.rb +13 -4
  5. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  6. data/lib/rigor/cache/rbs_environment.rb +2 -1
  7. data/lib/rigor/cli/annotate_command.rb +57 -7
  8. data/lib/rigor/cli/coverage_command.rb +126 -0
  9. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  10. data/lib/rigor/cli/coverage_report.rb +75 -0
  11. data/lib/rigor/cli/mcp_command.rb +70 -0
  12. data/lib/rigor/cli.rb +73 -3
  13. data/lib/rigor/environment/rbs_loader.rb +46 -5
  14. data/lib/rigor/environment/reporters.rb +3 -2
  15. data/lib/rigor/environment.rb +159 -4
  16. data/lib/rigor/inference/def_return_typer.rb +98 -0
  17. data/lib/rigor/inference/expression_typer.rb +143 -12
  18. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  19. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  20. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  21. data/lib/rigor/inference/precision_scanner.rb +131 -0
  22. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  23. data/lib/rigor/mcp/loop.rb +43 -0
  24. data/lib/rigor/mcp/server.rb +263 -0
  25. data/lib/rigor/mcp.rb +16 -0
  26. data/lib/rigor/plugin/base.rb +28 -5
  27. data/lib/rigor/plugin/manifest.rb +33 -5
  28. data/lib/rigor/plugin/registry.rb +21 -0
  29. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  30. data/lib/rigor/sig_gen/generator.rb +150 -75
  31. data/lib/rigor/type/combinator.rb +57 -0
  32. data/lib/rigor/version.rb +1 -1
  33. data/sig/rigor/analysis/baseline.rbs +39 -0
  34. data/sig/rigor/environment.rbs +3 -2
  35. data/sig/rigor/type.rbs +4 -0
  36. data/sig/rigor.rbs +2 -0
  37. metadata +32 -1
@@ -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
- candidates_from_defs + collect_attr_candidates(parse_result.value, path, scope_index)
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
- body = def_node.body
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
- AttrWalkContext = Struct.new(:path, :scope_index, :out, keyword_init: true)
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, out: [])
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
- def ivar_type_lookup(scope_index, class_name)
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 = any_scope.class_ivars_for(class_name)
969
- ->(attr_name) { ivars[:"@#{attr_name}"] }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.1.9"
4
+ VERSION = "0.1.10"
5
5
  end
@@ -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
@@ -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