rigortype 0.4.0 → 0.4.1

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/handbook/08-understanding-errors.md +3 -2
  4. data/docs/install.md +87 -15
  5. data/docs/manual/02-cli-reference.md +2 -2
  6. data/docs/manual/03-configuration.md +5 -0
  7. data/docs/manual/08-skills.md +4 -3
  8. data/docs/manual/12-caching.md +43 -4
  9. data/docs/manual/14-rails-quickstart.md +19 -11
  10. data/lib/rigor/analysis/check_rules/source_arity.rb +163 -59
  11. data/lib/rigor/analysis/check_rules.rb +118 -87
  12. data/lib/rigor/analysis/dependency_recorder.rb +200 -18
  13. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
  14. data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
  15. data/lib/rigor/analysis/incremental_session.rb +273 -49
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  17. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  18. data/lib/rigor/analysis/run_cache_key.rb +27 -8
  19. data/lib/rigor/analysis/run_stats.rb +2 -2
  20. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +1 -1
  21. data/lib/rigor/analysis/runner/pool_coordinator.rb +2 -2
  22. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  23. data/lib/rigor/analysis/runner.rb +337 -46
  24. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  25. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  26. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  27. data/lib/rigor/analysis/template_units.rb +15 -5
  28. data/lib/rigor/analysis/worker_session.rb +4 -3
  29. data/lib/rigor/cache/descriptor.rb +40 -1
  30. data/lib/rigor/cache/file_digest.rb +28 -0
  31. data/lib/rigor/cache/incremental_snapshot.rb +70 -8
  32. data/lib/rigor/cache/store.rb +63 -3
  33. data/lib/rigor/cache/value_digest.rb +325 -0
  34. data/lib/rigor/cli/check_command.rb +83 -13
  35. data/lib/rigor/cli/doctor_command.rb +1 -1
  36. data/lib/rigor/cli/effects_report.rb +2 -2
  37. data/lib/rigor/cli/unused_command.rb +3 -3
  38. data/lib/rigor/cli.rb +1 -1
  39. data/lib/rigor/config_audit.rb +3 -2
  40. data/lib/rigor/config_path.rb +29 -0
  41. data/lib/rigor/configuration/severity_profile.rb +5 -0
  42. data/lib/rigor/configuration.rb +6 -5
  43. data/lib/rigor/effects/plugin_facts.rb +6 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  45. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  46. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  47. data/lib/rigor/environment/rbs_loader.rb +1 -1
  48. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  49. data/lib/rigor/environment.rb +13 -70
  50. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  51. data/lib/rigor/inference/captured_locals.rb +7 -1
  52. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  53. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  54. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  55. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  56. data/lib/rigor/inference/declaration_walk.rb +7 -0
  57. data/lib/rigor/inference/def_node_resolver.rb +3 -2
  58. data/lib/rigor/inference/definer_resolution.rb +266 -0
  59. data/lib/rigor/inference/expression_typer.rb +136 -45
  60. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  61. data/lib/rigor/inference/guard_rebinding.rb +53 -12
  62. data/lib/rigor/inference/macro_block_self_type.rb +14 -2
  63. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +46 -26
  64. data/lib/rigor/inference/module_function_state.rb +213 -0
  65. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  66. data/lib/rigor/inference/narrowing.rb +147 -17
  67. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  68. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  69. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  70. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  71. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  72. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  73. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
  74. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  75. data/lib/rigor/inference/scope_indexer.rb +1443 -295
  76. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  77. data/lib/rigor/inference/statement_evaluator.rb +80 -14
  78. data/lib/rigor/plugin/base.rb +28 -8
  79. data/lib/rigor/plugin/io_boundary.rb +82 -4
  80. data/lib/rigor/plugin/isolation.rb +1 -1
  81. data/lib/rigor/plugin/trust_policy.rb +3 -3
  82. data/lib/rigor/protection/discovery_seed.rb +18 -9
  83. data/lib/rigor/protection/mutation_cache.rb +3 -3
  84. data/lib/rigor/reflection/constant_ancestors.rb +96 -47
  85. data/lib/rigor/reflection/constant_path.rb +22 -7
  86. data/lib/rigor/reflection.rb +2 -3
  87. data/lib/rigor/scope/discovery_index.rb +218 -2
  88. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  89. data/lib/rigor/scope/resolution_chain.rb +884 -0
  90. data/lib/rigor/scope.rb +295 -322
  91. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  92. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  93. data/lib/rigor/sig_gen/generator.rb +22 -16
  94. data/lib/rigor/type/combinator.rb +25 -1
  95. data/lib/rigor/version.rb +1 -1
  96. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
  97. data/sig/rigor/environment.rbs +2 -0
  98. data/sig/rigor/plugin/io_boundary.rbs +1 -0
  99. data/sig/rigor/scope.rbs +43 -1
  100. data/skills/rigor-project-init/SKILL.md +37 -11
  101. data/skills/rigor-project-init/references/02-configure.md +7 -2
  102. metadata +25 -1
@@ -311,12 +311,12 @@ module Rigor
311
311
  # `[class_name, …ancestors]`, memoised per class. Cycle-guarded and capped: the tables are *as
312
312
  # written*, so a project can spell an ancestry that loops.
313
313
  #
314
- # Included and prepended modules are walked beside the superclass, nearer first, because that is
315
- # Ruby's own lookup order and because a framework's contract is as often a module as a base class:
316
- # a Sidekiq worker is `class TriggerWebhookWorker; include Sidekiq::Job`, with no base class at all,
317
- # so a superclass-only walk could not match a row about it however the plugin spelled one (#456).
318
- # `ExpressionTyper#enqueue_ancestors` already resolves a self-call this way; the two tables
319
- # disagreeing about what an ancestor is was the bug.
314
+ # Included and prepended modules are walked beside the superclass, nearer first, because a framework's
315
+ # contract is as often a module as a base class: a Sidekiq worker is `class TriggerWebhookWorker;
316
+ # include Sidekiq::Job`, with no base class at all, so a superclass-only walk could not match a row
317
+ # about it however the plugin spelled one (#456). Breadth-first is close to Ruby's lookup order but not
318
+ # it (a module an included module includes comes after the superclass here); `Scope::ResolutionChain`
319
+ # is the exact order, and this walk over the plugin facts' own copy of the tables does not read it yet.
320
320
  def ancestry(class_name)
321
321
  @ancestry[class_name] ||= begin
322
322
  chain = []
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "yaml"
4
4
 
5
+ require_relative "../config_path"
6
+
5
7
  module Rigor
6
8
  class Environment
7
9
  # Target-project Bundler awareness (O4, implemented).
@@ -167,7 +169,7 @@ module Rigor
167
169
  # for the stats banner so end users can see what rigor picked up.
168
170
  def self.resolve_bundle_path(bundle_path:, project_root: Dir.pwd, auto_detect: true, home: nil)
169
171
  if bundle_path
170
- path = Pathname.new(File.expand_path(bundle_path.to_s, project_root))
172
+ path = Pathname.new(Rigor::ConfigPath.absolute(bundle_path.to_s, project_root))
171
173
  return path if path.directory?
172
174
 
173
175
  return nil
@@ -203,7 +205,7 @@ module Rigor
203
205
  # read of the real `~/.bundle/config`).
204
206
  def self.auto_detect(project_root:, home: nil)
205
207
  from_config = read_bundle_config_path(File.join(project_root, ".bundle", "config"))
206
- return File.expand_path(from_config, project_root) if from_config
208
+ return Rigor::ConfigPath.absolute(from_config, project_root) if from_config
207
209
 
208
210
  vendor = File.join(project_root, "vendor", "bundle")
209
211
  return vendor if File.directory?(vendor)
@@ -221,7 +223,7 @@ module Rigor
221
223
  configured = read_bundle_config_path(File.join(home, ".bundle", "config"))
222
224
  return nil unless configured
223
225
 
224
- resolved = File.expand_path(configured, project_root)
226
+ resolved = Rigor::ConfigPath.absolute(configured, project_root)
225
227
  File.directory?(resolved) ? resolved : nil
226
228
  end
227
229
  private_class_method :global_bundle_path
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../config_path"
4
+
3
5
  module Rigor
4
6
  class Environment
5
7
  # Gemfile.lock parser for target-project bundler awareness (O4 Layer 3, implemented).
@@ -109,11 +111,18 @@ module Rigor
109
111
  end
110
112
  private_class_method :do_parse_dependency_names
111
113
 
114
+ # The absolute path a configured `lockfile:` names, whether or not the file exists. The one place the
115
+ # setting is expanded (a leading `~/` is the home directory), shared by `resolve_lockfile_path` and
116
+ # by `WriteGuard`, which must watch the very path the run reads.
117
+ def self.configured_lockfile_path(lockfile_path, project_root = Dir.pwd)
118
+ Rigor::ConfigPath.absolute(lockfile_path.to_s, project_root)
119
+ end
120
+
112
121
  # Returns the resolved lockfile path (`Pathname`) or `nil` when neither explicit nor auto-detect
113
122
  # produces one. Public so the stats banner can show what rigor picked up.
114
123
  def self.resolve_lockfile_path(lockfile_path:, project_root: Dir.pwd, auto_detect: true)
115
124
  if lockfile_path
116
- path = Pathname.new(File.expand_path(lockfile_path.to_s, project_root))
125
+ path = Pathname.new(configured_lockfile_path(lockfile_path, project_root))
117
126
  return path if path.file?
118
127
 
119
128
  return nil
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "yaml"
4
4
 
5
+ require_relative "../config_path"
6
+
5
7
  module Rigor
6
8
  class Environment
7
9
  # `rbs collection install` awareness (O4 Layer 3 slice 2, implemented).
@@ -68,11 +70,18 @@ module Rigor
68
70
  gem_paths_from(collection_root, data, skip_gem_names.to_set)
69
71
  end
70
72
 
73
+ # The absolute path a configured `lockfile:` names, whether or not the file exists. The one place the
74
+ # setting is expanded (a leading `~/` is the home directory), shared by `resolve_lockfile_path` and
75
+ # by `WriteGuard`, which must watch the very path the run reads.
76
+ def self.configured_lockfile_path(lockfile_path, project_root = Dir.pwd)
77
+ Rigor::ConfigPath.absolute(lockfile_path.to_s, project_root)
78
+ end
79
+
71
80
  # Returns the resolved lockfile path (`Pathname`) or `nil` when neither explicit nor auto-detect
72
81
  # produces one. Public so the stats banner can surface what rigor found.
73
82
  def self.resolve_lockfile_path(lockfile_path:, project_root: Dir.pwd, auto_detect: true)
74
83
  if lockfile_path
75
- path = Pathname.new(File.expand_path(lockfile_path.to_s, project_root))
84
+ path = Pathname.new(configured_lockfile_path(lockfile_path, project_root))
76
85
  return path if path.file?
77
86
 
78
87
  return nil
@@ -812,7 +812,7 @@ module Rigor
812
812
  buffer_name = location&.buffer&.name
813
813
  return false unless buffer_name
814
814
 
815
- project_files.include?(File.expand_path(buffer_name.to_s))
815
+ project_files.include?(File.absolute_path(buffer_name.to_s))
816
816
  end
817
817
 
818
818
  # Adds empty stub declarations for the missing referenced types (and any enclosing namespace they
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../cache/value_digest"
5
+
6
+ module Rigor
7
+ class Environment
8
+ # ADR-32 WD5 — one plugin's `source_rbs_synthesizer` output for one source file, cached per (file content,
9
+ # plugin entry, engine source). Two callers read it, and sharing the function is the point:
10
+ #
11
+ # - {Environment.collect_virtual_rbs} hands each output to the loader as a `virtual:` buffer;
12
+ # - {.digest} reduces every loaded synthesizer's output for a file to one comparable value, which the
13
+ # incremental session (ADR-89 WD1, issue #1536) keeps on the file's seed bundle and compares across an
14
+ # edit.
15
+ #
16
+ # Because the digest reads the very function (and the very cache entries) the loader is fed from, "the
17
+ # digest did not move" means "the loader read the same bytes for this file", not "some model of what
18
+ # the plugin reads did not move". That is why it is taken from the output rather than from the file's
19
+ # comment lines: rbs-inline binds an annotation by adjacency, so a plain comment or a blank line can move
20
+ # one to another declaration, and in an annotated file every `def`, `attr`, constant and mixin gets a
21
+ # skeleton whether or not it carries an annotation of its own.
22
+ module SourceRbsSynthesis
23
+ PRODUCER_ID = "plugin.source_rbs_synthesizer"
24
+
25
+ # The digest of a file no loaded synthesizer contributes anything for — the answer for every file of a
26
+ # project without an annotation, and for every file when no synthesizer is loaded at all. Not a hex
27
+ # digest, so it can never collide with one.
28
+ NO_CONTRIBUTION = "none"
29
+
30
+ module_function
31
+
32
+ # The cache stores the empty string `""` as the "no contribution" sentinel because `Cache::Store`
33
+ # treats `nil` as a cache miss. Error tuples are stored as the canonical `[:error, message_string]`
34
+ # Array so the same wrapper short-circuits subsequent runs against unchanged broken input.
35
+ def output_for(plugin, callable, path, cache_store)
36
+ return invoke_safely(callable, path) if cache_store.nil?
37
+ return invoke_safely(callable, path) unless File.file?(path)
38
+
39
+ descriptor = cache_descriptor(plugin, path)
40
+ return invoke_safely(callable, path) if descriptor.nil?
41
+
42
+ cache_store.fetch_or_compute(
43
+ producer_id: PRODUCER_ID,
44
+ params: {},
45
+ descriptor: descriptor,
46
+ generation_cap: generation_cap
47
+ ) { invoke_safely(callable, path) || "" }
48
+ end
49
+
50
+ # Issue #1536 — every synthesizer's output for `path`, reduced to one value: {NO_CONTRIBUTION} when none
51
+ # of them contributes anything, a SHA-256 hex digest of each plugin's id and {.contribution} otherwise,
52
+ # and nil when an output could not be read at all.
53
+ #
54
+ # nil means "unknown", and a caller must treat it as moved. Every failure is per file: one plugin that
55
+ # cannot answer for one file leaves every other file's digest intact.
56
+ #
57
+ # @param synthesizers — `Plugin::Registry#source_rbs_synthesizers` pairs.
58
+ def digest(synthesizers, path, cache_store)
59
+ sha = Digest::SHA256.new
60
+ contributed = false
61
+ synthesizers.each do |plugin, callable|
62
+ tag, text = contribution(output_for(plugin, callable, path, cache_store))
63
+ next if tag.nil?
64
+
65
+ contributed = true
66
+ sha << plugin.manifest.id.to_s.b << "\x00" << tag << "\x00" << text.to_s.b << "\x01"
67
+ end
68
+ contributed ? sha.hexdigest : NO_CONTRIBUTION
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ # What one output puts into the environment, as a `[tag, text]` pair, or nil for nothing: exactly the
74
+ # RBS {Environment.interpret_synthesizer_outcome} hands the loader. The ADR-32 WD6 / WD12 notices are left
75
+ # out. They reach only the run-level `source-rbs-*` rows, which every run regenerates and the per-file
76
+ # cache never serves, and they quote line numbers, so a line shift in a file carrying a malformed `#:`
77
+ # would otherwise read as a moved contribution. A failed synthesis contributes nothing to the loader but
78
+ # is kept as one stable value of its own, so a file flipping between "no annotation" and "broken
79
+ # annotation" still reads as moved. Any shape the contract does not name is digested whole, by value: the
80
+ # output is computed on one run and served from its cache entry on the next, and a digest of its
81
+ # `Marshal.dump` bytes can differ between the two (issue #1574).
82
+ #
83
+ # The RBS text itself is digested whole, comments included. RBS reads a `# resolve-type-names: false`
84
+ # magic comment at the start of a buffer, rbs-inline copies a `.rb` file's first comment line there, and
85
+ # a string-literal type may span lines that start with `#`, so no line-based stripping is sound (#1549).
86
+ def contribution(output)
87
+ return nil if output.nil? || output == ""
88
+ return ["rbs", output] if output.is_a?(String)
89
+ return ["raw", Cache::ValueDigest.hexdigest(output)] unless output.is_a?(Array)
90
+
91
+ case output[0]
92
+ when :error then ["error", nil]
93
+ when :ok then contribution(output[1])
94
+ else ["raw", Cache::ValueDigest.hexdigest(output)]
95
+ end
96
+ end
97
+
98
+ # One entry per (plugin, source file), all of them live for as long as the file is in the project — a
99
+ # generation count says nothing about staleness here, so this producer declares itself out of
100
+ # `Cache::Store#evict!`'s compaction pass and is bounded only by the size-based LRU pass. A method
101
+ # rather than a constant: `Cache::Store` is not loaded yet when this file is.
102
+ def generation_cap
103
+ Cache::Store::UNBOUNDED_GENERATIONS
104
+ end
105
+
106
+ # The key composes the file's content SHA with the plugin's `PluginEntry` (id + version + config_hash),
107
+ # so a config change or a content change invalidates the entry, and with the engine's source identity
108
+ # (issue #1009): a plugin's manifest version does not move when a checkout edits its synthesizer, and
109
+ # a stale string here also keeps the `rbs.virtual_rbs` env key warm, so the new build's rules would
110
+ # read the old build's RBS.
111
+ def cache_descriptor(plugin, path)
112
+ Cache::Descriptor.new(
113
+ files: [Cache::Descriptor::FileEntry.new(
114
+ path: path.to_s,
115
+ comparator: :digest,
116
+ value: input_digest(path)
117
+ )],
118
+ plugins: [plugin.plugin_entry],
119
+ configs: Cache::EngineSource.key_config_entries
120
+ )
121
+ rescue Cache::EngineSource::Unavailable
122
+ # An engine that cannot be identified must not be keyed by its inputs alone (issue #1009): nil runs the
123
+ # synthesizer uncached rather than serving an entry another build may have written.
124
+ nil
125
+ end
126
+
127
+ def input_digest(path)
128
+ Digest::SHA256.hexdigest(File.binread(path))
129
+ rescue ::SystemCallError
130
+ # Unreadable file → key on the path alone; the synthesizer's File.file?/File.read will see the same
131
+ # failure and return nil.
132
+ Digest::SHA256.hexdigest(path.to_s)
133
+ end
134
+
135
+ def invoke_safely(callable, path)
136
+ callable.call(path.to_s)
137
+ rescue StandardError
138
+ # WD6 fail-soft — a synthesizer that raises does NOT crash analysis. Unlike the `[:error, msg]`
139
+ # return path (which the runner surfaces as `source-rbs-synthesis-failed`), an unhandled raise is
140
+ # swallowed silently; the unexamined-raise channel is deliberately silent per WD6.
141
+ nil
142
+ end
143
+
144
+ private_class_method :contribution, :generation_cap, :cache_descriptor, :input_digest, :invoke_safely
145
+ end
146
+ end
147
+ end
@@ -15,6 +15,7 @@ require_relative "environment/bundle_sig_discovery"
15
15
  require_relative "environment/lockfile_resolver"
16
16
  require_relative "environment/installed_gem_set"
17
17
  require_relative "environment/rbs_collection_discovery"
18
+ require_relative "environment/source_rbs_synthesis"
18
19
  require_relative "plugin/isolation"
19
20
  require_relative "cache/engine_source"
20
21
  require_relative "environment/rbs_coverage_report"
@@ -275,7 +276,7 @@ module Rigor
275
276
  rbs_collection_lockfile: nil, rbs_collection_auto_detect: false,
276
277
  synthetic_method_index: nil, project_patched_methods: nil,
277
278
  source_files: [], locked_gems: nil)
278
- resolved_paths = signature_paths || default_signature_paths(root)
279
+ resolved_paths = project_signature_roots(signature_paths, root: root)
279
280
  # O4 MVP — append per-gem `sig/` directories discovered under the target project's bundler install
280
281
  # root. Empty array when neither an explicit path nor auto-detection finds a bundle. Order: user
281
282
  # `signature_paths:` win first (semantic precedence inside `RbsLoader.build_env_for`); gem-shipped
@@ -390,6 +391,13 @@ module Rigor
390
391
  )
391
392
  end
392
393
 
394
+ # The project-owned `sig/` roots a run reads: the explicit `signature_paths:` when given, else the
395
+ # auto-detected `<root>/sig` (or none). Public so {Cache::IncrementalSnapshot} digests exactly the
396
+ # directories the environment loads, including the auto-detected one a `nil` `signature_paths:` hides.
397
+ def project_signature_roots(signature_paths, root: Dir.pwd)
398
+ signature_paths || default_signature_paths(root)
399
+ end
400
+
393
401
  private
394
402
 
395
403
  def default_signature_paths(root)
@@ -496,11 +504,9 @@ module Rigor
496
504
  # empty and this method short-circuits to `[]` without walking the file list.
497
505
  #
498
506
  # WD5 — when `cache_store` is supplied, each (file, plugin) synthesizer call is memoised through
499
- # `Cache::Store`. The cache key composes the file's content SHA with the plugin's `PluginEntry` (id +
500
- # version + config_hash) so a config change or content change invalidates the entry automatically, and
501
- # with the engine's source identity (issue #1009): a plugin's manifest version does not move when a
502
- # checkout edits its synthesizer, and a stale string here also keeps the `rbs.virtual_rbs` env key warm,
503
- # so the new build's rules would read the old build's RBS.
507
+ # `Cache::Store` ({SourceRbsSynthesis.output_for}, which owns the key). The incremental session's
508
+ # per-file digest (issue #1536) reads the same function, so the two can never disagree on what a file
509
+ # contributed.
504
510
  def collect_virtual_rbs(plugin_registry, source_files, cache_store, reporter)
505
511
  return [] if plugin_registry.nil?
506
512
 
@@ -511,7 +517,7 @@ module Rigor
511
517
  result = []
512
518
  source_files.each do |path|
513
519
  synthesizers.each do |plugin, callable|
514
- outcome = synthesizer_output_for(plugin, callable, path, cache_store)
520
+ outcome = SourceRbsSynthesis.output_for(plugin, callable, path, cache_store)
515
521
  outcome = interpret_synthesizer_outcome(outcome, plugin, path, reporter)
516
522
  next if outcome.nil? || outcome.empty?
517
523
 
@@ -522,25 +528,6 @@ module Rigor
522
528
  result
523
529
  end
524
530
 
525
- # ADR-32 WD5 — cache wrapper around a single (plugin, file) invocation. The cache stores the empty
526
- # string `""` as the "no contribution" sentinel because `Cache::Store` treats `nil` as a cache miss.
527
- # Error tuples are stored as the canonical `[:error, message_string]` Array so the same wrapper
528
- # short-circuits subsequent runs against unchanged broken input.
529
- def synthesizer_output_for(plugin, callable, path, cache_store)
530
- return invoke_synthesizer_safely(callable, path) if cache_store.nil?
531
- return invoke_synthesizer_safely(callable, path) unless File.file?(path)
532
-
533
- descriptor = build_synthesizer_cache_descriptor(plugin, path)
534
- return invoke_synthesizer_safely(callable, path) if descriptor.nil?
535
-
536
- cache_store.fetch_or_compute(
537
- producer_id: SYNTHESIZER_CACHE_PRODUCER_ID,
538
- params: {},
539
- descriptor: descriptor,
540
- generation_cap: synthesizer_generation_cap
541
- ) { invoke_synthesizer_safely(callable, path) || "" }
542
- end
543
-
544
531
  # ADR-32 WD6 / WD12 — route a synthesizer return value through the per-run reporter. The
545
532
  # synthesizer's contract (declared in
546
533
  # `plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb`) admits four return shapes:
@@ -661,50 +648,6 @@ module Rigor
661
648
  prefix = "#{root}#{File::SEPARATOR}"
662
649
  path.to_s.start_with?(prefix) ? path.to_s.delete_prefix(prefix) : path.to_s
663
650
  end
664
-
665
- SYNTHESIZER_CACHE_PRODUCER_ID = "plugin.source_rbs_synthesizer"
666
- private_constant :SYNTHESIZER_CACHE_PRODUCER_ID
667
-
668
- # One entry per (plugin, source file), all of them live for as long as the file is in the project — a
669
- # generation count says nothing about staleness here, so this producer declares itself out of
670
- # `Cache::Store#evict!`'s compaction pass and is bounded only by the size-based LRU pass. A method
671
- # rather than a constant: `Cache::Store` is not loaded yet when this class body runs.
672
- def synthesizer_generation_cap
673
- Cache::Store::UNBOUNDED_GENERATIONS
674
- end
675
-
676
- def build_synthesizer_cache_descriptor(plugin, path)
677
- Cache::Descriptor.new(
678
- files: [Cache::Descriptor::FileEntry.new(
679
- path: path.to_s,
680
- comparator: :digest,
681
- value: synthesizer_input_digest(path)
682
- )],
683
- plugins: [plugin.plugin_entry],
684
- configs: Cache::EngineSource.key_config_entries
685
- )
686
- rescue Cache::EngineSource::Unavailable
687
- # An engine that cannot be identified must not be keyed by its inputs alone (issue #1009): nil runs the
688
- # synthesizer uncached rather than serving an entry another build may have written.
689
- nil
690
- end
691
-
692
- def synthesizer_input_digest(path)
693
- Digest::SHA256.hexdigest(File.binread(path))
694
- rescue ::SystemCallError
695
- # Unreadable file → key on the path alone; the synthesizer's File.file?/File.read will see the same
696
- # failure and return nil.
697
- Digest::SHA256.hexdigest(path.to_s)
698
- end
699
-
700
- def invoke_synthesizer_safely(callable, path)
701
- callable.call(path.to_s)
702
- rescue StandardError
703
- # WD6 fail-soft — a synthesizer that raises does NOT crash analysis. Unlike the `[:error, msg]`
704
- # return path (which the runner surfaces as `source-rbs-synthesis-failed`), an unhandled raise is
705
- # swallowed silently; the unexamined-raise channel is deliberately silent per WD6.
706
- nil
707
- end
708
651
  end
709
652
 
710
653
  # Resolves a constant name to a Rigor::Type::Nominal (the *instance* type carrier). Consults the static
@@ -54,14 +54,16 @@ module Rigor
54
54
  reset_per_bind_state
55
55
  end
56
56
 
57
- # The names the last {#bind} bound optimistically nil-free (see the class comment).
57
+ # The names the last {#bind} bound optimistically nil-free (see the class comment). Read-only: it is a
58
+ # shared frozen empty when the bind marked nothing.
58
59
  attr_reader :optimistic
59
60
 
60
61
  # Binds the block's parameters into `scope`: {#bind}'s types through `Scope#with_local`, then the
61
- # optimistic mark for every name in {#optimistic}.
62
+ # optimistic mark for every name in {#optimistic}. It applies them through
63
+ # {MultiTargetBinder.apply_bindings} directly rather than through a `Result`, which every block entry
64
+ # built only to apply at once ([#1504](https://github.com/rigortype/rigor/issues/1504)).
62
65
  def bind_onto(block_node, scope)
63
- types = bind(block_node, scope: scope)
64
- MultiTargetBinder::Result.new(types: types, optimistic: @optimistic.dup.freeze).apply_to(scope)
66
+ MultiTargetBinder.apply_bindings(scope, bind(block_node, scope: scope), @optimistic)
65
67
  end
66
68
 
67
69
  # @return ordered map from parameter name to bound type. Anonymous
@@ -90,29 +92,40 @@ module Rigor
90
92
 
91
93
  private
92
94
 
95
+ NO_POSITIONS = [].freeze
96
+ NO_NAMES = [].freeze
97
+ private_constant :NO_POSITIONS, :NO_NAMES
98
+
93
99
  # {#apply_auto_splat} rewrites the positional table for the block it is binding, so every {#bind} starts
94
100
  # from the declared types again; a binder reused across blocks must not see the previous block's splat.
101
+ # The two lists start as shared frozen empties, since most blocks mark nothing ({#mark_optimistic}).
95
102
  def reset_per_bind_state
96
103
  @expected_param_types = @declared_param_types
97
104
  @splat_rest_type = nil
98
- @optimistic_positions = []
99
- @optimistic = []
105
+ @optimistic_positions = NO_POSITIONS
106
+ @optimistic = NO_NAMES
100
107
  @scope = nil
101
108
  end
102
109
 
110
+ # Records `name` as bound optimistically, allocating {#optimistic} on the first mark of a {#bind}.
111
+ def mark_optimistic(name)
112
+ @optimistic = [] if @optimistic.equal?(NO_NAMES)
113
+ @optimistic << name
114
+ end
115
+
103
116
  # `|_1, _2|` numbered-parameter form. Prism exposes the implicit count through
104
117
  # `NumberedParametersNode#maximum` (the highest `_N` referenced in the body); we materialise bindings
105
118
  # for `:_1` through `:_maximum`, auto-splatting exactly as the explicit list of that many required
106
119
  # positionals would, so the body's `LocalVariableReadNode` lookups see the same types and marks.
107
120
  def bind_numbered_parameters(numbered_node)
108
121
  arity = numbered_node.maximum
109
- apply_auto_splat(BlockAutoSplat::ParameterShape.of_arity(arity))
122
+ apply_auto_splat { BlockAutoSplat::ParameterShape.of_arity(arity) }
110
123
 
111
124
  bindings = {}
112
125
  arity.times do |i|
113
126
  name = :"_#{i + 1}"
114
127
  bindings[name] = positional_type_at(i)
115
- @optimistic << name if @optimistic_positions.include?(i)
128
+ mark_optimistic(name) if @optimistic_positions.include?(i)
116
129
  end
117
130
  bindings
118
131
  end
@@ -127,7 +140,7 @@ module Rigor
127
140
  params_node = params_root.parameters
128
141
  return {} if params_node.nil?
129
142
 
130
- apply_auto_splat(BlockAutoSplat::ParameterShape.of(params_node))
143
+ apply_auto_splat { BlockAutoSplat::ParameterShape.of(params_node) }
131
144
 
132
145
  bindings = {}
133
146
  bind_positionals(params_node, bindings, 0)
@@ -152,9 +165,12 @@ module Rigor
152
165
  # and {BlockAutoSplat} answers a table for that value and `shape`. A multi-arg yield (e.g.
153
166
  # `each_with_index`'s `(element, index)` pair) is NOT auto-splatted — matching Ruby semantics where a
154
167
  # multi-arg yield to a `|a, b, c|` block fills the extra slot with nil rather than splatting any
155
- # element.
156
- def apply_auto_splat(shape)
168
+ # element. The block answers the parameter list's shape, built only past that test
169
+ # ([#1504](https://github.com/rigortype/rigor/issues/1504)).
170
+ def apply_auto_splat
157
171
  return unless @expected_param_types.size == 1
172
+
173
+ shape = yield
158
174
  return unless shape.splats?
159
175
 
160
176
  table = BlockAutoSplat.for(shape, @expected_param_types[0])
@@ -242,12 +258,12 @@ module Rigor
242
258
  case param
243
259
  when Prism::RequiredParameterNode
244
260
  bindings[param.name] = positional_type_at(cursor)
245
- @optimistic << param.name if @optimistic_positions.include?(cursor)
261
+ mark_optimistic(param.name) if @optimistic_positions.include?(cursor)
246
262
  when Prism::MultiTargetNode
247
263
  nested = MultiTargetBinder.bind_marked(param, positional_type_at(cursor),
248
264
  optimistic: @optimistic_positions.include?(cursor), scope: @scope)
249
265
  bindings.merge!(nested.types)
250
- @optimistic.concat(nested.optimistic)
266
+ nested.optimistic.each { |name| mark_optimistic(name) }
251
267
  end
252
268
  end
253
269
 
@@ -413,10 +413,16 @@ module Rigor
413
413
  NESTED_SCOPE_NODES = Set[Prism::BlockNode, Prism::LambdaNode].freeze
414
414
  private_constant :NESTED_SCOPE_NODES
415
415
 
416
+ # The index arm spells {INDEX_STORE_NODES} out rather than splatting it: `when *ARRAY` copies the array
417
+ # on every evaluation, and this runs once per node of every scanned block body, which cost `rigor check`
418
+ # about 126K allocations over the v0.3.9 `lib` ([#1502](https://github.com/rigortype/rigor/issues/1502),
419
+ # the shape `ScopeIndexer::META_CONSTANT_WRITE_NODES` records). Keep the arm and the list in step.
416
420
  def mutated_receiver(node)
417
421
  case node
418
422
  when Prism::CallNode then node.receiver if MutationWidening::SHAPE_MUTATORS.include?(node.name)
419
- when *INDEX_STORE_NODES then node.receiver
423
+ when Prism::IndexOrWriteNode, Prism::IndexAndWriteNode, Prism::IndexOperatorWriteNode,
424
+ Prism::IndexTargetNode
425
+ node.receiver
420
426
  end
421
427
  end
422
428