rigortype 0.4.2 → 0.4.3

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +12 -5
  4. data/docs/manual/02-cli-reference.md +19 -1
  5. data/docs/manual/03-configuration.md +6 -4
  6. data/docs/manual/05-inspecting-types.md +50 -0
  7. data/docs/manual/12-caching.md +18 -1
  8. data/docs/manual/14-rails-quickstart.md +2 -2
  9. data/docs/manual/18-removing-dead-code.md +16 -9
  10. data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
  11. data/lib/rigor/analysis/check_rules.rb +23 -22
  12. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  13. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  14. data/lib/rigor/analysis/incremental_session.rb +89 -22
  15. data/lib/rigor/analysis/path_expansion.rb +62 -6
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  17. data/lib/rigor/analysis/reachability/graph.rb +119 -15
  18. data/lib/rigor/analysis/reachability/scan.rb +332 -173
  19. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  20. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  21. data/lib/rigor/analysis/run_stats.rb +28 -6
  22. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
  23. data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
  24. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  25. data/lib/rigor/analysis/runner.rb +29 -11
  26. data/lib/rigor/analysis/worker_session.rb +1 -1
  27. data/lib/rigor/cache/engine_source.rb +21 -7
  28. data/lib/rigor/cache/incremental_snapshot.rb +36 -8
  29. data/lib/rigor/cli/check_command.rb +138 -39
  30. data/lib/rigor/cli/docs_command.rb +5 -4
  31. data/lib/rigor/cli/lens_command.rb +144 -0
  32. data/lib/rigor/cli/lsp_command.rb +5 -2
  33. data/lib/rigor/cli/skill_command.rb +3 -3
  34. data/lib/rigor/cli/skill_describe.rb +1 -1
  35. data/lib/rigor/cli/type_of_command.rb +2 -1
  36. data/lib/rigor/cli/unused_command.rb +157 -24
  37. data/lib/rigor/cli.rb +30 -4
  38. data/lib/rigor/configuration.rb +15 -0
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  40. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  41. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  42. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  43. data/lib/rigor/environment/rbs_loader.rb +33 -0
  44. data/lib/rigor/environment/required_features.rb +4 -2
  45. data/lib/rigor/environment.rb +12 -5
  46. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  47. data/lib/rigor/inference/captured_locals.rb +43 -3
  48. data/lib/rigor/inference/expression_typer.rb +58 -25
  49. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  50. data/lib/rigor/inference/match_rebinding.rb +1 -1
  51. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  52. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  53. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  54. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  55. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  56. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  57. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  58. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  59. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  60. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  61. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  62. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  63. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  64. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  65. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  66. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  67. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  68. data/lib/rigor/inference/scope_indexer.rb +140 -5
  69. data/lib/rigor/inference/statement_evaluator.rb +223 -31
  70. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  71. data/lib/rigor/language_server/project_context.rb +56 -5
  72. data/lib/rigor/lens/anchor.rb +115 -0
  73. data/lib/rigor/lens/contract_reader.rb +118 -0
  74. data/lib/rigor/lens/document.rb +113 -0
  75. data/lib/rigor/lens/file_reader.rb +433 -0
  76. data/lib/rigor/lens/members.rb +153 -0
  77. data/lib/rigor/lens/parameters.rb +91 -0
  78. data/lib/rigor/lens/plugins.rb +40 -0
  79. data/lib/rigor/lens/run.rb +280 -0
  80. data/lib/rigor/lens/slot.rb +79 -0
  81. data/lib/rigor/lens.rb +40 -0
  82. data/lib/rigor/project_environment.rb +20 -1
  83. data/lib/rigor/protection/mutation_cache.rb +2 -1
  84. data/lib/rigor/scope.rb +22 -11
  85. data/lib/rigor/sig_gen/generator.rb +45 -5
  86. data/lib/rigor/version.rb +1 -1
  87. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  88. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  89. data/sig/rigor/scope.rbs +3 -0
  90. data/sig/rigor.rbs +6 -3
  91. data/skills/rigor-project-init/references/02-configure.md +2 -2
  92. metadata +18 -1
@@ -24,18 +24,31 @@ module Rigor
24
24
  # `class_decl_paths` (a frozen `Hash<String, String>` populated once per environment by the RBS loader;
25
25
  # ~1000-2000 entries × one `String#start_with?`).
26
26
  class RunStats
27
- attr_reader :wall_seconds, :peak_rss_bytes,
27
+ attr_reader :wall_seconds,
28
28
  :target_files,
29
29
  :rbs_classes_total, :rbs_classes_project_sig, :rbs_classes_bundled,
30
30
  :gem_walk_classes, :gem_walk_gems, :rbs_attribution_available
31
31
 
32
- def initialize(wall_seconds:, peak_rss_bytes:, # rubocop:disable Metrics/ParameterLists
32
+ # The live probe a run's summary uses: reads this process's RSS when first asked. The probe is a constant
33
+ # lambda, so the RunStats it rides on never captures the Runner (and its analysis state) through `Result`.
34
+ CURRENT_PROCESS_PEAK_RSS = Ractor.make_shareable(-> { RunStats.peak_rss_bytes })
35
+
36
+ # Issue #1529 — the peak RSS is read on the first {#peak_rss_bytes} call, not when the summary is built. A run
37
+ # that prints no summary (`--incremental` recheck, `--no-stats`) therefore never forks `ps` or reads `/proc`.
38
+ # The read stays in this process: the summary is built and printed by the process that ran the analysis.
39
+ #
40
+ # @param peak_rss_bytes — a value to report directly (specs, or a caller that already read it). Ignored when
41
+ # `peak_rss_probe:` is given.
42
+ # @param peak_rss_probe — a zero-argument callable returning the RSS in bytes, called at most once, lazily.
43
+ def initialize(wall_seconds:, # rubocop:disable Metrics/ParameterLists
33
44
  target_files:,
34
45
  rbs_classes_total:, rbs_classes_project_sig:, rbs_classes_bundled:,
35
46
  gem_walk_classes:, gem_walk_gems:,
36
- rbs_attribution_available: true)
47
+ rbs_attribution_available: true, peak_rss_bytes: nil, peak_rss_probe: nil)
37
48
  @wall_seconds = wall_seconds
38
- @peak_rss_bytes = peak_rss_bytes
49
+ @peak_rss_probe = peak_rss_probe || -> { peak_rss_bytes }
50
+ # A frozen RunStats cannot memoise into an ivar, so the first read lands in this (unfrozen) Hash.
51
+ @peak_rss_memo = {}
39
52
  @target_files = target_files
40
53
  @rbs_classes_total = rbs_classes_total
41
54
  @rbs_classes_project_sig = rbs_classes_project_sig
@@ -46,6 +59,15 @@ module Rigor
46
59
  freeze
47
60
  end
48
61
 
62
+ # The peak RSS in bytes, read once on first call and memoised (nil when no route answers). The check command
63
+ # forces the first call straight after the analysis (#1529), before `--coverage` or any output, so the figure
64
+ # covers the analysis alone. On Linux it is the kernel's VmHWM high-water mark, so a later read is never below
65
+ # the run-end value. On macOS it is `ps`'s current RSS, so the reading is the process as it stood at that
66
+ # first call, not a high-water mark.
67
+ def peak_rss_bytes
68
+ @peak_rss_memo.fetch(:value) { @peak_rss_memo[:value] = @peak_rss_probe.call }
69
+ end
70
+
49
71
  # Reports the process's resident set size in bytes. Source ordering: `/proc/self/status` (Linux — reads
50
72
  # `VmHWM:`, the peak RSS the kernel records) first; otherwise `ps -o rss= -p <pid>` (macOS / BSD —
51
73
  # reports CURRENT RSS, the closest universally-available proxy). Returns nil when neither route works
@@ -146,7 +168,7 @@ module Rigor
146
168
  end
147
169
  out.puts("#{prefix}Process")
148
170
  out.puts("#{prefix} Wall time: #{Kernel.format('%.2fs', @wall_seconds)}")
149
- out.puts("#{prefix} Memory peak: #{format_bytes(@peak_rss_bytes)}")
171
+ out.puts("#{prefix} Memory peak: #{format_bytes(peak_rss_bytes)}")
150
172
  end
151
173
 
152
174
  def to_h
@@ -159,7 +181,7 @@ module Rigor
159
181
  gem_walk_classes: @gem_walk_classes,
160
182
  gem_walk_gems: @gem_walk_gems,
161
183
  wall_seconds: @wall_seconds,
162
- peak_rss_bytes: @peak_rss_bytes
184
+ peak_rss_bytes: peak_rss_bytes
163
185
  }
164
186
  end
165
187
 
@@ -128,7 +128,32 @@ module Rigor
128
128
  source_family: :builtin
129
129
  )
130
130
  end
131
- not_found + Array(pre_eval_diagnostics_from_scanner).map { |hash| diagnostic_from_hash(hash) }
131
+ scanned = Array(pre_eval_diagnostics_from_scanner).map { |hash| diagnostic_from_hash(hash) }
132
+ suppress_pre_eval(not_found + scanned)
133
+ end
134
+
135
+ # The ordinary suppression pipeline for the `pre-eval.*` rows: the `# rigor:disable` /
136
+ # `# rigor:disable-file` comments of the file a row is positioned in (a `pre_eval:` file need not be
137
+ # under `paths:`, so its comments are read here rather than by per-file analysis) and the project's
138
+ # `disable:` list. A row positioned at `.rigor.yml` has no Ruby comments (`.yml` is skipped) and answers to
139
+ # `disable:` only.
140
+ # Stream order is the output contract, so survivors are picked out of the original sequence.
141
+ def suppress_pre_eval(diagnostics)
142
+ kept = diagnostics.group_by(&:path).flat_map do |path, group|
143
+ CheckRules.filter_suppressed(
144
+ group, comments: pre_eval_comments(path), disabled_rules: @configuration.disabled_rules
145
+ )
146
+ end
147
+ kept_ids = kept.to_set(&:object_id)
148
+ diagnostics.select { |diagnostic| kept_ids.include?(diagnostic.object_id) }
149
+ end
150
+
151
+ def pre_eval_comments(path)
152
+ return [].freeze unless File.file?(path) && !path.end_with?(".rbs", ".yml", ".yaml")
153
+
154
+ Prism.parse_file(path).comments
155
+ rescue StandardError
156
+ [].freeze
132
157
  end
133
158
 
134
159
  def diagnostic_from_hash(hash)
@@ -46,7 +46,7 @@ module Rigor
46
46
  synthetic_method_index:, project_patched_methods:,
47
47
  analyze_file:, project_scope_seed: -> { {} }, record_dependencies: false,
48
48
  restored_run_level_rows: nil, template_units: -> { TemplateUnits.empty },
49
- feature_sources: -> { {} })
49
+ feature_sources: -> { {} }, plugin_prepare: -> { [[], []] })
50
50
  @configuration = configuration
51
51
  @cache_store = cache_store
52
52
  @explain = explain
@@ -84,6 +84,10 @@ module Rigor
84
84
  # inherits the same compiled bytes.
85
85
  @template_units_reader = template_units
86
86
  @analyze_file = analyze_file
87
+ # Issue #1541 — `() -> [rows, disclosure records]`, a `#prepare` run on a throwaway registry. Read
88
+ # only by {#serve_empty_closure}: a pooled run's `#prepare` happens inside its workers, and an
89
+ # empty closure dispatches none.
90
+ @plugin_prepare = plugin_prepare
87
91
  # Issues #796 / #794 — the previous full run's run-level rows, handed down by
88
92
  # {Analysis::IncrementalSession} on a NARROWED run only (a recheck's closure, a
89
93
  # `--verify-incremental` partition). A full run leaves this nil and derives everything itself.
@@ -146,10 +150,8 @@ module Rigor
146
150
  # (`expansion.fetch(:files)`), independent of any `analyze_only` narrowing of `files`; see
147
151
  # {#serve_empty_closure} for what an empty `files` does with it.
148
152
  def analyze_files(files, environment: nil, project_files: nil)
149
- if files.empty?
150
- serve_empty_closure(environment, project_files)
151
- return []
152
- end
153
+ return serve_empty_closure(environment, project_files) if files.empty?
154
+
153
155
  # Issue #784 / #793 — `files` is what this run ANALYSES; `source_files` is what its environment
154
156
  # is BUILT over, and the two are the same only on a full run. A narrowed run (`analyze_only`: an
155
157
  # incremental closure, a `--verify-incremental` partition) used to build its environment over the
@@ -199,7 +201,16 @@ module Rigor
199
201
  # whether anyone could have demanded anything at all: with no files, nobody could have, and an
200
202
  # empty project keeps paying no env build. Keyed on the project's files rather than on
201
203
  # `analyze_only`, because a recheck over an EMPTY project narrows to `Set[]`, which is non-nil.
204
+ #
205
+ # Issue #1541 — and, in pool mode, its `prepare` rows. A pooled run's `#prepare` happens inside the
206
+ # workers and its rows reach the stream through their first payload, so the coordinator-side
207
+ # pre-pass skips it ({ProjectPrePasses#run}) and {DiagnosticAggregator#pre_file_diagnostics} omits
208
+ # the cached rows; with no worker dispatched the row appeared nowhere, and a plugin whose `#prepare`
209
+ # raises turned a red project green on a nothing-changed `--incremental --workers N` recheck. They
210
+ # are returned as the run's per-file stream head, where the pool path puts them (`.rigor.yml`).
211
+ # Sequential, whose pre-pass already ran `#prepare`, returns no rows here.
202
212
  def serve_empty_closure(environment, project_files)
213
+ prepare_rows = pool_mode? ? pooled_prepare_rows : []
203
214
  env = environment || @environment_override
204
215
  # Resolved over the project's OWN file list — not `[]`, which would drop every plugin-synthesized
205
216
  # virtual RBS (`Environment.collect_virtual_rbs` short-circuits on an empty list) and scan a
@@ -217,9 +228,16 @@ module Rigor
217
228
  # `env` rather than `resolve.call`: read the demand the snapshots above just made when they built
218
229
  # an environment, and force nothing when they did not.
219
230
  record_definition_build_failures(env&.rbs_loader&.definition_build_failures)
220
- return if replayed_restored_run_level_rows?
231
+ record_hkt_scan_failure(hkt_scan_outcome(resolve.call)) unless replayed_restored_run_level_rows?
232
+ prepare_rows
233
+ end
221
234
 
222
- record_hkt_scan_failure(hkt_scan_outcome(resolve.call))
235
+ # Issue #1541 — the pooled empty closure's `#prepare`: the rows go to the caller, the run-scoped
236
+ # registrations into the table the workers' payloads would have filled.
237
+ def pooled_prepare_rows
238
+ rows, records = @plugin_prepare.call
239
+ merge_run_disclosures(records)
240
+ Array(rows)
223
241
  end
224
242
 
225
243
  # Issue #794 — the signature-state snapshot's own gates, asked BEFORE an environment exists. Every
@@ -700,8 +718,9 @@ module Rigor
700
718
  # Issue #1051, known exception: run-scoped disclosures a plugin registered from `#prepare` are NOT
701
719
  # recovered here — nor, since #1060, a positioned `#emit_once` batch registered there, which rides
702
720
  # the same table. There is no session to drain — this backend builds none on the coordinator — and
703
- # `#prepare` deliberately does not run on the coordinator-side registry under pool mode, so a
704
- # prepare-time disclosure lives only inside the Ractor that died with it. A disclosure registered
721
+ # `#prepare` does not run on the coordinator-side registry under pool mode (only an empty closure
722
+ # runs it, on a throwaway registry; see {#serve_empty_closure}), so a prepare-time disclosure lives
723
+ # only inside the Ractor that died with it. A disclosure registered
705
724
  # from `#diagnostics_for_file` IS recovered, because the re-analysis below runs the coordinator-side
706
725
  # plugin instances that {DiagnosticAggregator#plugin_run_disclosure_diagnostics} reads directly.
707
726
  # Left as is: recovering the rest means running `#prepare` on the coordinator registry, which would
@@ -59,7 +59,7 @@ module Rigor
59
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
61
  :struct_member_layouts, :discovered_deferred_ranges, :discovered_refinements, :siblings,
62
- :discovered_global_write_census
62
+ :discovered_global_write_census, :singleton_class_sources
63
63
  )
64
64
 
65
65
  # Internal: drives every EAGER project-wide pre-pass — the ones whose products feed the RBS
@@ -175,6 +175,8 @@ module Rigor
175
175
  # object's `extend`s reach `Narrowing` from a sibling file the way its `include`s already do.
176
176
  discovered_extends: def_index.fetch(:extends), unpositioned_mixins: def_index.fetch(:unpositioned_mixins),
177
177
  discovered_class_sources: def_index.fetch(:class_sources),
178
+ # Issue #1752 — the files opening `class << Const`, for the class-declaration diff alone.
179
+ singleton_class_sources: def_index.fetch(:singleton_class_sources, {}),
178
180
  # Issue #644 — the cross-file value-constant publication table and its write attribution.
179
181
  constant_values: def_index.fetch(:constant_values), constant_sources: def_index.fetch(:constant_sources),
180
182
  constant_writes: def_index.fetch(:constant_writes),
@@ -251,6 +253,24 @@ module Rigor
251
253
  registry
252
254
  end
253
255
 
256
+ # Issue #1541 — what a pooled run's workers would have produced from `#prepare`, for the coordinator's
257
+ # empty-closure branch, which dispatches none: the `#prepare` error rows and the run-scoped
258
+ # registrations (`#disclose_once` / `#emit_once`) made there. It runs on a THROWAWAY registry, so
259
+ # nothing `#prepare` records on a plugin instance (a trust refusal, say) reaches the coordinator-side
260
+ # instances {DiagnosticAggregator} reads — a pooled cold run never shows those either.
261
+ # Returns the rows first, then the disclosure records.
262
+ def prepare_throwaway_registry
263
+ registry = load_plugins
264
+ rows = plugin_prepare_diagnostics(registry)
265
+ records = registry.plugins.flat_map do |plugin|
266
+ plugin_id = safe_plugin_id(plugin)
267
+ Array(plugin.run_disclosure_records).map { |record| record.merge(plugin_id: plugin_id) }
268
+ rescue StandardError
269
+ []
270
+ end
271
+ [rows, records]
272
+ end
273
+
254
274
  # Returns the per-run shared `Plugin::FactStore` instance. All loaded plugins share this store
255
275
  # through their respective `Plugin::Services` (the same instance is threaded by
256
276
  # `Plugin::Loader.load`). Returns `nil` when no plugins are loaded.
@@ -297,8 +297,13 @@ module Rigor
297
297
  record_dependencies: false, record_self_calls: false, analyze_only: nil,
298
298
  seed_bundles: nil, collect_seed_bundles: false, param_inferred_types: nil,
299
299
  discovery_seed: nil, no_tolerated_effects: false,
300
- restored_run_level_rows: nil, served_per_file_diagnostics: nil)
300
+ restored_run_level_rows: nil, served_per_file_diagnostics: nil, on_file_indexed: nil)
301
301
  @configuration = configuration
302
+ # ADR-113 WD2 — `rigor lens` reads a target's seeded index here: called with `(path, parse_result, scope,
303
+ # index)` for every file this process indexes, after its rules ran and inside the run window, so the reader
304
+ # sees what `check` saw. A pooled file is indexed in a worker and never reaches it; the incremental session
305
+ # analyses forced targets with `workers: 0` for that reason.
306
+ @on_file_indexed = on_file_indexed
302
307
  # Issues #796 / #794 — the previous full run's run-level rows, supplied only by
303
308
  # {IncrementalSession} on a narrowed run; see {PoolCoordinator#replay_restored_run_level_rows}.
304
309
  @restored_run_level_rows = restored_run_level_rows
@@ -431,6 +436,7 @@ module Rigor
431
436
  # Issue #1367 — the project's `Inference::GlobalWriteCensus`, which the `global.*` write rules read.
432
437
  @project_discovered_global_write_census = Set.new.freeze
433
438
  @project_discovered_class_sources = {}.freeze
439
+ @project_singleton_class_sources = {}.freeze
434
440
  # Issue #644 — the cross-file VALUE-constant publication table (`{qualified name => Type::Constant}`,
435
441
  # literal writes only) and its per-name write attribution. The first seeds `in_source_constants` on
436
442
  # every per-file scope so `FOO = :sym` in one file reads as `:sym` in another; the second seeds
@@ -648,8 +654,8 @@ module Rigor
648
654
  # subclasses (`subclass_of:<last segment>`).
649
655
  def class_declarations
650
656
  result = Hash.new { |hash, key| hash[key] = Set.new }
651
- @project_discovered_class_sources.each do |class_name, files|
652
- files.each { |file| result[file] << class_name }
657
+ [@project_discovered_class_sources, @project_singleton_class_sources].each do |sources|
658
+ sources.each { |class_name, files| files.each { |file| result[file] << class_name } }
653
659
  end
654
660
  Incremental.add_superclass_edges(result, @project_discovered_class_sources, @project_discovered_superclasses)
655
661
  result.transform_values(&:freeze).freeze
@@ -1748,6 +1754,8 @@ module Rigor
1748
1754
  @project_discovered_prepends = discovery.discovered_prepends
1749
1755
  @project_discovered_extends = discovery.discovered_extends
1750
1756
  @project_unpositioned_mixins = discovery.unpositioned_mixins
1757
+ # Issue #1752 — the files that open `class << Const`, read only by {#class_declarations}.
1758
+ @project_singleton_class_sources = discovery.singleton_class_sources
1751
1759
  end
1752
1760
 
1753
1761
  # Internal: builds the deferred cross-file discovery tables at most once per run and adopts them.
@@ -1804,10 +1812,8 @@ module Rigor
1804
1812
  # the pre-pass then saw two writers for every constant a file declares and withheld its publication (#644),
1805
1813
  # and every file was a discovered-not-analysed dependency of the run's cache entry.
1806
1814
  #
1807
- # The widening decision itself still compares the paths as spelled. `exclude:` patterns match a path as it
1808
- # is spelled, so until #1576 is fixed the configured absolute `lib` can discover files the argument `lib`
1809
- # excludes (a relative `lib/gen/**` pattern never matches `/abs/lib/gen/x.rb`), and treating the two as the
1810
- # same root would drop those files from discovery.
1815
+ # The widening decision itself compares the paths as spelled. `exclude:` patterns apply to both spellings
1816
+ # alike (#1576), so the configured absolute `lib` and the argument `lib` exclude the same files.
1811
1817
  def project_discovery_expansion(paths, expansion)
1812
1818
  return nil unless widen_discovery_to_project?(paths)
1813
1819
 
@@ -2017,7 +2023,8 @@ module Rigor
2017
2023
  template_units: -> { template_units },
2018
2024
  analyze_file: ->(path, environment) { analyze_file(path, environment) },
2019
2025
  restored_run_level_rows: @restored_run_level_rows,
2020
- feature_sources: -> { required_feature_sources }
2026
+ feature_sources: -> { required_feature_sources },
2027
+ plugin_prepare: -> { @pre_passes.prepare_throwaway_registry }
2021
2028
  )
2022
2029
  @diagnostic_aggregator = DiagnosticAggregator.new(
2023
2030
  configuration: @configuration,
@@ -2063,7 +2070,8 @@ module Rigor
2063
2070
  )
2064
2071
  RunStats.new(
2065
2072
  wall_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at,
2066
- peak_rss_bytes: RunStats.peak_rss_bytes,
2073
+ # Issue #1529 — read lazily on the summary's first use, so a run that prints none never forks for it.
2074
+ peak_rss_probe: RunStats::CURRENT_PROCESS_PEAK_RSS,
2067
2075
  # #392 — the template units count too: they are files this run parsed and typed, and a stats line
2068
2076
  # that omitted them would under-report exactly the work a views-heavy project added.
2069
2077
  target_files: expansion.fetch(:files).size + template_units.paths.size,
@@ -2176,7 +2184,8 @@ module Rigor
2176
2184
  bad = []
2177
2185
  Array(paths).each do |path|
2178
2186
  if File.directory?(path)
2179
- files.concat(PathExpansion.directory_files(path, @configuration.exclude_patterns))
2187
+ files.concat(PathExpansion.directory_files(path, @configuration.exclude_patterns,
2188
+ base: @configuration.exclude_base))
2180
2189
  # Editor-mode bypass: the buffer's logical path is treated as a real `.rb` file regardless of
2181
2190
  # on-disk presence — `parse_source` reads bytes from the buffer's physical path. Common case: LSP
2182
2191
  # client editing a brand-new file.
@@ -2591,10 +2600,11 @@ module Rigor
2591
2600
  # the rule is active or `record_self_calls:` opted in.
2592
2601
  index = nil
2593
2602
  self_call_record = with_self_call_recording(path) do
2594
- index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
2603
+ index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope, widen_clauses: true)
2595
2604
  end
2596
2605
  self_call_misses = self_call_record ? self_call_record.calls : []
2597
2606
  diagnostics = rule_and_plugin_diagnostics(path, parse_result, scope, index, self_call_misses)
2607
+ notify_file_indexed(path, parse_result, scope, index)
2598
2608
  diagnostics + explain_diagnostics(path, parse_result.value, scope)
2599
2609
  rescue Errno::ENOENT => e
2600
2610
  [
@@ -2618,6 +2628,14 @@ module Rigor
2618
2628
  ]
2619
2629
  end
2620
2630
 
2631
+ # ADR-113 WD2 — the `on_file_indexed` observer. It reads; an error inside it never changes this file's
2632
+ # diagnostics, so it is contained here rather than reaching {#analyze_file_body}'s crash row.
2633
+ def notify_file_indexed(path, parse_result, scope, index)
2634
+ @on_file_indexed&.call(path, parse_result, scope, index)
2635
+ rescue StandardError
2636
+ nil
2637
+ end
2638
+
2621
2639
  # ADR-53 B4 — the built-in node collectors and the plugin node rules share ONE traversal of the file.
2622
2640
  # The collectors are built here (they need the completed `index`) and populated by the converged
2623
2641
  # plugin walk; `node_results` carries the per-plugin node-rule output. Both the built-in `diagnose`
@@ -228,7 +228,7 @@ module Rigor
228
228
  scope = @template_units.seed(
229
229
  seed_project_scope(Scope.empty(environment: @environment, source_path: path)), path
230
230
  )
231
- index = Inference::ScopeIndexer.index(root, default_scope: scope)
231
+ index = Inference::ScopeIndexer.index(root, default_scope: scope, widen_clauses: true)
232
232
  # ADR-53 B4 — built-in collectors + plugin node rules share one walk.
233
233
  node_collectors = CheckRules.build_node_collectors(path, index, root)
234
234
  node_results = node_rule_results_by_plugin(path, root, scope, node_collectors, index)
@@ -59,6 +59,19 @@ module Rigor
59
59
  # `apps/` and `tool/` are not loaded by an analysis and stay out.
60
60
  SOURCE_DIRECTORIES = %w[lib plugins].freeze
61
61
 
62
+ # Engine DATA the analysis reads at runtime, relative to the gem root (issue #1492). A data edit moves
63
+ # diagnostics as surely as a `lib/` edit, yet the caches that key only on `signature_paths` plus this
64
+ # digest (the `--incremental` snapshot fingerprint, `Protection::MutationCache`) see nothing else.
65
+ #
66
+ # - YAML: `data/builtins` (`MethodCatalog::DATA_ROOT`) and `data/effects` (`Effects::Catalog`,
67
+ # `Effects::Registry`).
68
+ # - RBS: every `.rbs` under `data/` (`core_overlay`, `vendored_gem_sigs`, `capability_roles`,
69
+ # `gem_overlay`) plus each bundled plugin's `plugins/*/sig`. `RbsDescriptor` digests the same files
70
+ # into the run-result and env caches, but those are the only two consumers that do.
71
+ #
72
+ # Tooling-only trees (`data/oss-sweep`) are not read by an analysis and stay out.
73
+ DATA_GLOBS = %w[data/builtins/**/*.yml data/effects/**/*.yml data/**/*.rbs plugins/*/sig/**/*.rbs].freeze
74
+
62
75
  # The one directory whose absence means "this is not an engine tree". `plugins/` may legitimately be
63
76
  # missing (a slimmed install contributes nothing); a missing `lib/` would silently shrink the digest
64
77
  # to whatever else happened to be there, which is exactly the weakening this module forbids.
@@ -153,8 +166,9 @@ module Rigor
153
166
  !File.exist?(File.join(root, ".git"))
154
167
  end
155
168
 
156
- # A SHA-256 over every engine `.rb` file: its ROOT-RELATIVE path (so the digest survives moving or
157
- # re-cloning the checkout) followed by its bytes, in sorted path order.
169
+ # A SHA-256 over every engine `.rb` file and {DATA_GLOBS} data file: its ROOT-RELATIVE
170
+ # path (so the digest survives moving or re-cloning the checkout) followed by its bytes, in sorted path
171
+ # order.
158
172
  #
159
173
  # The walk itself is not memoised — {.process_identity} is where a production caller gets the
160
174
  # once-per-process value, and this stays the computation so a spec can point it at another tree.
@@ -178,12 +192,12 @@ module Rigor
178
192
  raise Unavailable, "engine source under #{root} could not be read: #{e.message}"
179
193
  end
180
194
 
181
- # Sorted absolute paths of every engine `.rb` file, across the directories that exist.
195
+ # Sorted, de-duplicated absolute paths of every engine `.rb` file and {DATA_GLOBS} data file, across
196
+ # the directories that exist. `plugins/*/sig/**/*.rbs` overlaps nothing else but is de-duplicated anyway.
182
197
  def source_files(root)
183
- SOURCE_DIRECTORIES.flat_map do |relative|
184
- directory = File.join(root, relative)
185
- File.directory?(directory) ? Dir.glob(File.join(directory, "**", "*.rb")) : []
186
- end.sort
198
+ sources = SOURCE_DIRECTORIES.flat_map { |relative| Dir.glob(File.join(root, relative, "**", "*.rb")) }
199
+ data = DATA_GLOBS.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }
200
+ (sources + data).uniq.sort
187
201
  end
188
202
  end
189
203
  end
@@ -260,7 +260,11 @@ module Rigor
260
260
  # {EngineSource.process_identity} closes it, and answers nil for a version-pinned tree — which adds no
261
261
  # part, so a released gem's fingerprint is byte-identical to the pre-#289 one and its warm snapshots
262
262
  # survive the upgrade untouched.
263
- def self.fingerprint(configuration:, roots:)
263
+ #
264
+ # Issue #1533 — `explain:` is the run's `--explain` flag. It adds `:info` rows to the per-file diagnostics
265
+ # the snapshot caches, so a snapshot written without it holds none and one written with it holds rows a
266
+ # plain run must not print. No part for the default, so every plain run's fingerprint is as it was.
267
+ def self.fingerprint(configuration:, roots:, explain: false)
264
268
  bundler_lock, collection_lock = Analysis::RunCacheKey.resolved_lockfile_paths(configuration)
265
269
  parts = [
266
270
  "engine:#{Rigor::VERSION}:#{SCHEMA}",
@@ -279,14 +283,11 @@ module Rigor
279
283
  # dependency edge records, so the whole snapshot goes. Scanned over the configured paths plus the roots'
280
284
  # files, the set the run's environment scans; no part when nothing is required, so every other
281
285
  # project's fingerprint is as it was.
282
- features = Environment::RequiredFeatures.for_configuration(
283
- configuration, Analysis::PathExpansion.ruby_files(roots, configuration.exclude_patterns)
284
- )
286
+ features = required_features(configuration, roots)
285
287
  parts << "required-features:#{features.join(',')}" unless features.empty?
286
288
  # ADR-116 WD5 — the snapshot's per-file rows and seed bundles are only as checked as the run that
287
289
  # wrote them ({Inference::DeclarationWalk::Shadow}); nil with the harness off adds no part.
288
- shadow = Analysis::ShadowHarness.cache_identity
289
- parts << "shadow-harness:#{shadow}" if shadow
290
+ parts.concat(run_mode_parts(explain))
290
291
  Digest::SHA256.hexdigest(parts.join("\x00"))
291
292
  rescue StandardError
292
293
  # {EngineSource::Unavailable} lands here too, and nil is the answer it requires rather than one it
@@ -297,6 +298,12 @@ module Rigor
297
298
  nil
298
299
  end
299
300
 
301
+ def self.required_features(configuration, roots)
302
+ files = Analysis::PathExpansion.ruby_files(roots, configuration.exclude_patterns,
303
+ base: configuration.exclude_base)
304
+ Environment::RequiredFeatures.for_configuration(configuration, files)
305
+ end
306
+
300
307
  # ADR-10 slice 3, issue #1814 — the walked gems' versions: their method catalogue, their refinement table and the
301
308
  # constants they bind reach every file's checks, and the lockfile digest above does not cover a project with none.
302
309
  # No part for a project that lists no gem, so its fingerprint is what it was.
@@ -314,6 +321,15 @@ module Rigor
314
321
  end
315
322
  private_class_method :digest_file_if_present
316
323
 
324
+ # The parts naming how the run was asked to behave rather than what it reads: the ADR-116 WD5 shadow harness
325
+ # (the snapshot's rows are only as checked as the run that wrote them; nil with the harness off) and
326
+ # `--explain` (#1533).
327
+ def self.run_mode_parts(explain)
328
+ shadow = Analysis::ShadowHarness.cache_identity
329
+ [("shadow-harness:#{shadow}" if shadow), ("explain:true" if explain)].compact
330
+ end
331
+ private_class_method :run_mode_parts
332
+
317
333
  # Content-digest every `pre_eval:` file in configuration order, so an edit to one invalidates the snapshot
318
334
  # even when it lies outside the analysed paths. A missing file digests as "absent", so creating it counts too.
319
335
  def self.digest_pre_eval(paths)
@@ -346,8 +362,20 @@ module Rigor
346
362
  end
347
363
  private_class_method :digest_signature_paths
348
364
 
349
- def initialize(root:)
350
- @path = File.join(root.to_s, "incremental", "snapshot.bin")
365
+ # Issue #1867 — one snapshot FILE per fingerprint mode. A run whose fingerprint carries run-mode parts
366
+ # (`--explain`, the ADR-116 shadow harness) keeps its own file so it neither reads nor overwrites the
367
+ # plain run's warm snapshot, and the reverse. The plain file name is unchanged. The fingerprint still
368
+ # carries the mode parts, so the files never cross-serve.
369
+ def initialize(root:, explain: false)
370
+ @path = File.join(root.to_s, "incremental", self.class.file_name(explain: explain))
371
+ end
372
+
373
+ # `snapshot.bin` for a plain run; `snapshot-<mode digest>.bin` when {run_mode_parts} is non-empty.
374
+ def self.file_name(explain: false)
375
+ parts = run_mode_parts(explain)
376
+ return "snapshot.bin" if parts.empty?
377
+
378
+ "snapshot-#{Digest::SHA256.hexdigest(parts.join("\x00"))[0, 12]}.bin"
351
379
  end
352
380
 
353
381
  attr_reader :path