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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require_relative "runner"
5
+ require_relative "../cache/value_digest"
5
6
 
6
7
  module Rigor
7
8
  module Analysis
@@ -98,9 +99,9 @@ module Rigor
98
99
  def digest_registry(registry)
99
100
  return Result.new(digest: nil, opaque_plugin_ids: [].freeze) if registry.nil? || registry.empty?
100
101
 
101
- facts_by_plugin = facts_by_plugin(registry.plugins.first&.services&.fact_store)
102
- parts = fact_parts(facts_by_plugin)
103
102
  opaque = []
103
+ facts_by_plugin = facts_by_plugin(registry, opaque)
104
+ parts = fact_parts(facts_by_plugin)
104
105
  registry.plugins.each { |plugin| collect_plugin_parts(plugin, facts_by_plugin, parts, opaque) }
105
106
  # Sort the parts so the digest is independent of plugin registration / iteration order.
106
107
  Result.new(
@@ -129,16 +130,25 @@ module Rigor
129
130
 
130
131
  # `{ plugin_id => ["name=digest", ...] }` for every ADR-9 published fact (channel a). A plugin that
131
132
  # published nothing does not appear.
132
- def facts_by_plugin(fact_store)
133
+ #
134
+ # A fact the fingerprint cannot digest is an input it cannot see, so its publisher is opaque, whether or
135
+ # not the publisher contributes a type itself: other plugins read its facts. A fact store that cannot be
136
+ # read at all makes every plugin opaque. Both force the full run; dropping the facts instead would let a
137
+ # plugin with another surface reuse a snapshot its facts no longer match.
138
+ def facts_by_plugin(registry, opaque)
133
139
  result = Hash.new { |hash, key| hash[key] = [] }
134
- return result if fact_store.nil?
135
-
136
- fact_store.each_fact do |fact|
137
- result[fact.plugin_id.to_s] << "#{fact.name}=#{digest_value(fact.value)}"
140
+ fact_store = registry.plugins.first&.services&.fact_store
141
+ fact_store&.each_fact do |fact|
142
+ plugin_id = fact.plugin_id.to_s
143
+ begin
144
+ result[plugin_id] << "#{fact.name}=#{digest_value(fact.value)}"
145
+ rescue StandardError
146
+ opaque << plugin_id
147
+ end
138
148
  end
139
149
  result
140
150
  rescue StandardError
141
- # An unreadable fact store contributes nothing; opacity for contributing plugins is decided below.
151
+ registry.plugins.each { |plugin| opaque << safe_id(plugin) }
142
152
  Hash.new { |hash, key| hash[key] = [] }
143
153
  end
144
154
 
@@ -175,12 +185,13 @@ module Rigor
175
185
  true
176
186
  end
177
187
 
178
- # A stable content digest of an arbitrary fact / producer value. Producer values are Marshal-clean by
179
- # contract (the ADR-45 disk cache serialises them the same way), so this is the same round-trip the cache
180
- # already relies on. A Marshal failure raises to the caller, which marks the plugin opaque (never a
181
- # silent wrong-value digest).
188
+ # A digest of a fact / producer / hook value that depends on the value alone (issue #1574). A producer's
189
+ # value is computed on one run and served from its ADR-45 cache entry on the next; the two are equal but
190
+ # do not share the same objects, so a digest of their `Marshal.dump` bytes differed and invalidated the
191
+ # snapshot on every such switch. {Cache::ValueDigest} reads no identity. A value it cannot digest raises
192
+ # to the caller, which marks the plugin opaque (never a silent wrong-value digest).
182
193
  def digest_value(value)
183
- Digest::SHA256.hexdigest(Marshal.dump(value))
194
+ Cache::ValueDigest.hexdigest(value)
184
195
  end
185
196
 
186
197
  def contributes_types?(plugin)
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "zlib"
6
6
 
7
7
  require_relative "../../version"
8
+ require_relative "../../cache/store"
8
9
 
9
10
  module Rigor
10
11
  module Analysis
@@ -94,7 +95,7 @@ module Rigor
94
95
  keep = @entries.merge(@live).select { |(_, path), _| File.exist?(path) }
95
96
  FileUtils.mkdir_p(File.dirname(@path))
96
97
  tmp = "#{@path}.#{Process.pid}.tmp"
97
- File.binwrite(tmp, Zlib::Deflate.deflate(Marshal.dump([@header, keep])))
98
+ File.binwrite(tmp, Zlib::Deflate.deflate(Marshal.dump([@header, keep]), Cache::Store::DEFLATE_LEVEL))
98
99
  File.rename(tmp, @path)
99
100
  nil
100
101
  rescue StandardError
@@ -21,6 +21,7 @@ require_relative "../environment/default_libraries"
21
21
  # lockfile paths without loading `rigor/environment` or the RBS machinery.
22
22
  require_relative "../environment/lockfile_resolver"
23
23
  require_relative "../environment/rbs_collection_discovery"
24
+ require_relative "shadow_harness"
24
25
 
25
26
  module Rigor
26
27
  module Analysis
@@ -93,7 +94,7 @@ module Rigor
93
94
  Cache::Descriptor.new(
94
95
  gems: [Cache::RbsDescriptor.rbs_gem_entry],
95
96
  configs: rbs_config_entries + engine_source_entries + lockfile_entries(configuration) +
96
- template_unit_entries(template_units_digest) + [
97
+ template_unit_entries(template_units_digest) + shadow_harness_entries + [
97
98
  config_entry("configuration", Marshal.dump(configuration.to_h)),
98
99
  config_entry("engine",
99
100
  "#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{explain}"),
@@ -116,6 +117,15 @@ module Rigor
116
117
  digest.nil? ? [] : [config_entry("template-units", digest)]
117
118
  end
118
119
 
120
+ # ADR-116 WD5 — a run under `RIGOR_SHADOW_RULE_WALK` compares every table and rule collector with its
121
+ # legacy oracle, and a divergence is a row of the result. Keying on it keeps a warm hit from answering
122
+ # for a run the harness never checked, and a divergence row from replaying into a run without it. No
123
+ # slot with the harness off, so no existing key moves.
124
+ def shadow_harness_entries
125
+ identity = ShadowHarness.cache_identity
126
+ identity.nil? ? [] : [config_entry("shadow-harness", identity)]
127
+ end
128
+
119
129
  def engine_source_entries
120
130
  Cache::EngineSource.key_config_entries
121
131
  end
@@ -137,16 +147,25 @@ module Rigor
137
147
  # Content-only — the resolved PATH is deliberately not in the payload, so a checkout that moves (a CI
138
148
  # runner's workspace, a renamed directory) still hits with an identical lockfile.
139
149
  def lockfile_entries(configuration)
140
- bundler = Environment::LockfileResolver.resolve_lockfile_path(
141
- lockfile_path: configuration.bundler_lockfile, auto_detect: configuration.bundler_auto_detect
142
- )
143
- collection = Environment::RbsCollectionDiscovery.resolve_lockfile_path(
144
- lockfile_path: configuration.rbs_collection_lockfile,
145
- auto_detect: configuration.rbs_collection_auto_detect
146
- )
150
+ bundler, collection = resolved_lockfile_paths(configuration)
147
151
  [lockfile_entry("bundler.lockfile", bundler), lockfile_entry("rbs_collection.lockfile", collection)]
148
152
  end
149
153
 
154
+ # The one resolution of the two dependency lockfiles — `[bundler, rbs_collection]`, each a Pathname or nil
155
+ # — shared by this key and by {Cache::IncrementalSnapshot.fingerprint} so the two caches identify the gem
156
+ # set by the same files (issue #1532): `bundler.lockfile:` / `rbs_collection.lockfile:` and auto-detection.
157
+ def resolved_lockfile_paths(configuration)
158
+ [
159
+ Environment::LockfileResolver.resolve_lockfile_path(
160
+ lockfile_path: configuration.bundler_lockfile, auto_detect: configuration.bundler_auto_detect
161
+ ),
162
+ Environment::RbsCollectionDiscovery.resolve_lockfile_path(
163
+ lockfile_path: configuration.rbs_collection_lockfile,
164
+ auto_detect: configuration.rbs_collection_auto_detect
165
+ )
166
+ ]
167
+ end
168
+
150
169
  # `nil` — no lockfile resolves — is itself part of the identity, so it carries its own sentinel rather
151
170
  # than dropping the slot: "absent" and "present but empty" must not key the same.
152
171
  ABSENT_LOCKFILE = "\0absent"
@@ -95,12 +95,12 @@ module Rigor
95
95
  # with any of the signature path prefixes (after expansion to absolute paths); `bundled` is the
96
96
  # remainder.
97
97
  def self.partition_classes(class_decl_paths:, signature_paths:)
98
- prefixes = Array(signature_paths).map { |p| File.expand_path(p.to_s) }
98
+ prefixes = Array(signature_paths).map { |p| File.absolute_path(p.to_s) }
99
99
  return [0, class_decl_paths.size] if prefixes.empty?
100
100
 
101
101
  project = 0
102
102
  class_decl_paths.each_value do |path|
103
- expanded = File.expand_path(path)
103
+ expanded = File.absolute_path(path)
104
104
  project += 1 if prefixes.any? { |prefix| expanded.start_with?("#{prefix}/") || expanded == prefix }
105
105
  end
106
106
  [project, class_decl_paths.size - project]
@@ -965,7 +965,7 @@ module Rigor
965
965
 
966
966
  # Issue #959 — surfaces the {Plugin::IoBoundary} refusal history each loaded plugin accumulated over
967
967
  # the whole run (prepare AND every per-file call: `#io_boundary` is memoised per plugin instance, so
968
- # one boundary sees both). `TrustPolicy#allow_read?` stays `File.expand_path`-only per ADR-2 — a
968
+ # one boundary sees both). `TrustPolicy#allow_read?` stays `File.absolute_path`-only per ADR-2 — a
969
969
  # project rooted under a symlink (macOS' `/tmp` → `/private/tmp` is the common case) can have every
970
970
  # plugin read fall outside its own read roots with nothing said about it. One `:info` row per plugin
971
971
  # that hit this, never one per path, naming the count and the first path so the row stays legible on
@@ -287,10 +287,10 @@ module Rigor
287
287
  buffers = failure.is_a?(Array) ? failure[3] : nil
288
288
  return false if buffers.nil?
289
289
 
290
- closure_paths = closure.to_set { |path| File.expand_path(path.to_s) }
290
+ closure_paths = closure.to_set { |path| File.absolute_path(path.to_s) }
291
291
  Array(buffers).any? do |name|
292
292
  path = virtual_buffer_path(name)
293
- !path.nil? && closure_paths.include?(File.expand_path(path))
293
+ !path.nil? && closure_paths.include?(File.absolute_path(path))
294
294
  end
295
295
  end
296
296
 
@@ -23,7 +23,7 @@ module Rigor
23
23
  # ivar surface, preserving the exact assignment order and timing the inline body had. The `pool_mode`
24
24
  # predicate is injected (it keys on `@workers` / `@buffer`, owned by the Runner) so this collaborator
25
25
  # never calls back into it.
26
- class ProjectPrePasses
26
+ class ProjectPrePasses # rubocop:disable Metrics/ClassLength -- the Discovery slots are one declaration per table
27
27
  # Frozen bundle of the EAGER (env-input) project-wide state the pre-passes produce — the slots whose
28
28
  # products feed the RBS environment build. Mirrors the env-input ivars `apply_pre_passes_result`
29
29
  # sets on the {Runner}. The cross-file discovery tables live in the separate {Discovery} bundle
@@ -53,12 +53,13 @@ module Rigor
53
53
  # consumes them and so never pays the double parse (see {#discover} + `Runner#ensure_project_discovery`).
54
54
  # The slot names mirror the discovery half of {Result} exactly.
55
55
  Discovery = Data.define(
56
- :discovered_classes, :discovered_def_nodes, :discovered_def_nestings,
57
- :discovered_singleton_def_nodes, :discovered_def_sources, :discovered_singleton_def_sources,
58
- :discovered_superclasses, :discovered_header_nestings, :discovered_includes, :discovered_prepends,
59
- :discovered_extends, :discovered_class_sources, :constant_values, :constant_sources, :constant_writes,
56
+ :discovered_classes, :discovered_def_nodes, :discovered_def_nestings, :discovered_singleton_def_nodes,
57
+ :discovered_def_sources, :discovered_singleton_def_sources, :discovered_superclasses,
58
+ :discovered_header_nestings, :discovered_includes, :discovered_prepends, :discovered_extends,
59
+ :unpositioned_mixins, :discovered_class_sources, :constant_values, :constant_sources, :constant_writes,
60
60
  :discovered_method_visibilities, :discovered_methods, :discovered_parameter_envelopes, :data_member_layouts,
61
- :struct_member_layouts, :discovered_deferred_ranges, :discovered_refinements, :discovered_global_write_census
61
+ :struct_member_layouts, :discovered_deferred_ranges, :discovered_refinements, :siblings,
62
+ :discovered_global_write_census
62
63
  )
63
64
 
64
65
  # Internal: drives every EAGER project-wide pre-pass — the ones whose products feed the RBS
@@ -172,7 +173,7 @@ module Rigor
172
173
  discovered_prepends: def_index.fetch(:prepends),
173
174
  # Issue #898 — the singleton-side mixin table, kept beside the instance-side one so a class
174
175
  # object's `extend`s reach `Narrowing` from a sibling file the way its `include`s already do.
175
- discovered_extends: def_index.fetch(:extends),
176
+ discovered_extends: def_index.fetch(:extends), unpositioned_mixins: def_index.fetch(:unpositioned_mixins),
176
177
  discovered_class_sources: def_index.fetch(:class_sources),
177
178
  # Issue #644 — the cross-file value-constant publication table and its write attribution.
178
179
  constant_values: def_index.fetch(:constant_values), constant_sources: def_index.fetch(:constant_sources),
@@ -186,7 +187,8 @@ module Rigor
186
187
  # `*_def_shadows_call?` predicates order `sig`-shadowing defs against.
187
188
  discovered_deferred_ranges: def_index.fetch(:deferred_ranges),
188
189
  discovered_refinements: def_index.fetch(:refinements), # Issue #1120
189
- discovered_global_write_census: def_index.fetch(:global_write_census) # Issue #1367
190
+ # Issue #1367 — the census; ADR-119 WD1 — `{sibling name => table}` for `DiscoveryIndex::SIBLINGS`.
191
+ discovered_global_write_census: def_index.fetch(:global_write_census), siblings: def_index.fetch(:siblings)
190
192
  )
191
193
  end
192
194
 
@@ -233,13 +235,19 @@ module Rigor
233
235
  # {Rigor::Plugin::Registry} WITHOUT the synthetic-method / dependency-source / pre-eval scanners. The
234
236
  # incremental fact-surface fingerprint probe ({Analysis::PluginFactFingerprint}) uses this to read the
235
237
  # ADR-9 fact store + drive the ADR-60 producers without paying for the full pre-pass or building the RBS
236
- # environment. `prepare` runs unconditionally here — the probe is always sequential (its `pool_mode?`
238
+ # environment. `prepare` runs whatever the pool mode — the probe is always sequential (its `pool_mode?`
237
239
  # reader returns false), so the pool-mode skip in {#run} does not apply. Prepare diagnostics are
238
240
  # discarded: a plugin that raises in `#prepare` publishes no facts, so its surface is simply absent from
239
241
  # the fingerprint, exactly as it would be absent from analysis.
240
- def prepared_registry
242
+ #
243
+ # Issue #1536 — `prepare: false` loads the plugins without running `#prepare`, for a caller that reads
244
+ # only what a manifest declares: the incremental session reads each plugin's `source_rbs_synthesizer`
245
+ # before its recheck runner exists. A synthesizer's output is a function of the file, the plugin's
246
+ # config and the engine — the key its `Cache::Store` entry already rests on — so `#prepare` has
247
+ # nothing to add to it, and skipping it keeps that read off every plugin's prepare-time scans.
248
+ def prepared_registry(prepare: true)
241
249
  registry = load_plugins
242
- plugin_prepare_diagnostics(registry) unless registry.empty?
250
+ plugin_prepare_diagnostics(registry) if prepare && !registry.empty?
243
251
  registry
244
252
  end
245
253
 
@@ -288,12 +296,12 @@ module Rigor
288
296
  def build_trust_policy
289
297
  trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
290
298
  roots = [Dir.pwd]
291
- Array(@configuration.signature_paths).each { |sp| roots << File.expand_path(sp) }
299
+ Array(@configuration.signature_paths).each { |sp| roots << File.absolute_path(sp) }
292
300
  trusted_gems.each do |gem_name|
293
301
  path = trusted_gem_root(gem_name)
294
302
  roots << path if path
295
303
  end
296
- @configuration.plugins_io_allowed_paths.each { |p| roots << File.expand_path(p) }
304
+ @configuration.plugins_io_allowed_paths.each { |p| roots << File.absolute_path(p) }
297
305
 
298
306
  Plugin::TrustPolicy.new(
299
307
  trusted_gems: trusted_gems,