rigortype 0.4.2 → 0.4.4

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -38,7 +38,13 @@ module Rigor
38
38
  # gate) can report what was re-analyzed versus served from cache. `added` / `removed` carry the
39
39
  # structural delta so {#run_incremental} (ADR-87 WD3) can recognise a zero-change recheck and skip the
40
40
  # unconditional snapshot rewrite.
41
- Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused) do
41
+ # `rebaselined` (#1898) is true when the recheck found its served effect collections stale and answered with a
42
+ # full baseline instead; see {#rebaseline_recheck}.
43
+ Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused, :templates, :rebaselined) do
44
+ def initialize(rebaselined: false, **rest)
45
+ super
46
+ end
47
+
42
48
  # A recheck that changed, added, and removed nothing — the session state is byte-equivalent to the
43
49
  # snapshot that was restored, so persisting it again would only rewrite identical bytes.
44
50
  def no_change?
@@ -68,9 +74,14 @@ module Rigor
68
74
  # `--verify-incremental` gate use) keeps the sequential recording path bit-for-bit unchanged. The fork
69
75
  # pool records each worker's cross-file reads and marshals them back (PoolCoordinator), so the
70
76
  # dependency graph a pooled recheck rebuilds equals the sequential one.
71
- def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil,
72
- workers: 0, buffer: nil)
77
+ def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
78
+ workers: 0, buffer: nil, explain: false, on_file_indexed: nil)
73
79
  @configuration = configuration
80
+ # ADR-113 WD2 — `rigor lens`: {#force_targets=} names files a {#recheck} analyses whether or not they changed,
81
+ # in this process, so `on_file_indexed` (see {Runner}) sees their seeded index. Neither moves a diagnostic:
82
+ # a forced file re-analyses under the same seeds its cached rows were computed with.
83
+ @force_targets = Set.new
84
+ @on_file_indexed = on_file_indexed
74
85
  @paths = paths
75
86
  # Editor mode option B (#146) — the in-flight buffer, threaded into every internal Runner so the
76
87
  # pre-passes and the closure re-analysis read the editor's bytes at the logical path. A session
@@ -81,6 +92,7 @@ module Rigor
81
92
  @cache_store = cache_store
82
93
  @plugin_requirer = plugin_requirer
83
94
  @workers = workers
95
+ @explain = explain # #1533: threaded into every Runner; keys the snapshot, and no slot is written
84
96
  # ADR-85 WD2 — per-file discovery seed bundles keyed by logical path. A cold baseline builds them; a
85
97
  # warm recheck folds them (re-walking only changed files) and refreshes the set. Ride the snapshot.
86
98
  @seed_bundles = {}
@@ -122,12 +134,31 @@ module Rigor
122
134
  # banner + `--cache-stats`) reads after `#run_incremental`. `@last_runner` is the analysis runner the
123
135
  # post-hoc fingerprint reads from; `@plugin_fact_reusable` is the computed decision object.
124
136
  @plugin_fact_digest = nil
125
- @opaque_plugin_ids = [].freeze
137
+ @opaque_reasons = {}.freeze
138
+ @fact_store_unreadable = false
126
139
  @fact_surface_invalidated = false
127
140
  @last_runner = nil
128
141
  @plugin_fact_reusable = nil
129
142
  end
130
143
 
144
+ # ADR-113 WD2 — the paths (as {#project_files} spells them) every later {#recheck} analyses in this process.
145
+ def force_targets=(paths)
146
+ @force_targets = paths.to_set
147
+ end
148
+
149
+ # The project files a run of this session analyses, as the runner expands the configured paths.
150
+ def project_files
151
+ current_files
152
+ end
153
+
154
+ # The per-file discovery bundles of the last run, keyed by path (ADR-85 WD2).
155
+ attr_reader :seed_bundles
156
+
157
+ # The project files `path`'s analysis read from at the last run that analysed it (ADR-46 slice 1).
158
+ def dependency_sources(path)
159
+ @sources.fetch(path, Set.new)
160
+ end
161
+
131
162
  # The project files analyzed at the last baseline / recheck — the set a verify pass partitions and the
132
163
  # merge subtracts the affected closure from.
133
164
  def analyzed_files
@@ -183,6 +214,7 @@ module Rigor
183
214
  # ADR-103 WD13 / #382 — a baseline collects every file, so its collections are the whole world.
184
215
  @effect_collections = runner.effect_collections_by_path
185
216
  @effects_identity = current_effects_identity
217
+ @effect_plugin_digest = effect_plugin_digest_of(runner)
186
218
  @cache = per_file(runner.per_file_diagnostics)
187
219
  @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
188
220
  forget_unbound_digests
@@ -206,21 +238,20 @@ module Rigor
206
238
  fresh_params = fresh_param_table(current, changed, added, removed)
207
239
  param_files, param_pairs = param_seed_invalidation(fresh_params)
208
240
  affected = affected_closure(changed, added, removed, param_files, param_pairs)
209
- analyze_set = affected & current
210
- reused = (current & previous) - affected.to_a
241
+ forced, analyze_set = forced_analyze_set(affected, current)
242
+ reused = (current & previous) - analyze_set.to_a
211
243
  # The freshly collected table is handed to the runner so the run seeds from the SAME table the diff
212
244
  # was decided on (and the collector runs once per recheck, not twice). The reused files' cached rows
213
245
  # go in too, so the runner lists them in the full run's file order (#1524).
214
246
  runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
215
- param_inferred_types: fresh_params,
216
- restored_run_level_rows: @run_level_rows,
217
- served_per_file_diagnostics: @cache.slice(*reused))
247
+ param_inferred_types: fresh_params, **forced_target_options(forced),
248
+ restored_run_level_rows: @run_level_rows, **served_state(reused))
218
249
  merged = run_runner(runner).diagnostics
219
- @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
250
+ return rebaseline_recheck(changed, added, removed) if served_effects_stale?(runner, reused)
251
+
220
252
  absorb(runner, current, analyze_set, removed)
221
253
  @param_table = fresh_params
222
- Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
223
- removed: removed.to_set, affected: affected, reused: reused.to_set)
254
+ recheck_result(merged, [changed, added, removed], affected: affected, reused: reused.to_set, runner: runner)
224
255
  end
225
256
 
226
257
  # Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
@@ -245,6 +276,9 @@ module Rigor
245
276
  # latency this mode exists to avoid, so decline and let the caller drop to single-file scope.
246
277
  break nil unless @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
247
278
  break nil if source_rbs_unverified?(result)
279
+ # #1898 — a recheck that re-baselined over stale plugin effect facts ran its baseline with the buffer set,
280
+ # which analyses the buffer alone, so it is not a whole-project answer. Decline, as above.
281
+ break nil if result.rebaselined
248
282
 
249
283
  result
250
284
  end
@@ -316,6 +350,35 @@ module Rigor
316
350
  closure.freeze
317
351
  end
318
352
 
353
+ # Issue #1906 — a file that rewrites a constant's singleton names without declaring it
354
+ # (`D.singleton_class.alias_method :own, :keep`) contributes opaque singleton envelopes to the constant's bucket,
355
+ # but a reader's class edge names only the files that declared the constant when the reader last ran, and the
356
+ # class-declaration diff does not move while the constant stays in the file (`:own` becoming `:keep`, or the
357
+ # call becoming a `class D` reopening). So a declaration-unstable edit to such a file fires `class:<last
358
+ # segment>` for each constant that, before the edit, both sat in the file's singleton-source set and held a
359
+ # singleton envelope entry or an unpositioned singleton side the file recorded (a computed name there leaves the
360
+ # side unpositioned, and becoming literal reads it again). A file that only includes into a constant
361
+ # (`ActiveRecord::Base.include(X)`) records neither and fires nothing. A constant the edit makes the file
362
+ # rewrite needs no key: the class-declaration diff fires if it is new to the file, and the reader's class edge
363
+ # names the file if the file declared it.
364
+ def singleton_rewrite_affected(unstable)
365
+ names = unstable.flat_map { |path| singleton_rewritten_constants(@seed_bundles[path]) }.uniq
366
+ return Set.new if names.empty?
367
+
368
+ Incremental.negative_closure(names.map { |name| "class:#{name.split('::').last}" }, @negative_dependents)
369
+ end
370
+
371
+ def singleton_rewritten_constants(bundle)
372
+ return [] if bundle.nil?
373
+
374
+ envelopes = bundle[:parameter_envelopes] || {}
375
+ unpositioned = bundle[:unpositioned_mixins] || {}
376
+ (bundle[:singleton_class_source_names] || []).select do |name|
377
+ (envelopes[name] || {}).each_key.any? { |key| key.is_a?(Array) && key.first == :singleton } ||
378
+ unpositioned.dig(name, :extend)
379
+ end
380
+ end
381
+
319
382
  # Issue #1367 — every file whose last result holds a `global.*` diagnostic re-checks on any recheck. What
320
383
  # exempts such a write is a fact of the whole program — an alias, or a `write` / `to_str` / `to_int` / hatch
321
384
  # defined in any spelling on any receiver, a rewritten ancestor, a mixin — which no name-keyed edge can
@@ -339,13 +402,15 @@ module Rigor
339
402
 
340
403
  # The dependents contributed by the declaration-unstable changed files and the behaviourally-unstable
341
404
  # symbol pairs: the ADR-46 slice-4 symbol-granular fan-out when either is present (ancestry deps of the
342
- # unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out.
405
+ # unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out; plus the readers of
406
+ # the constants an unstable file rewrote from outside their bodies ({#singleton_rewrite_affected}).
343
407
  def dependents_base(unstable, symbol_pairs)
344
- if symbol_pairs.any? || unstable.any? { |f| @ancestry_dependents[f] }
345
- Incremental.affected_with_symbols(unstable, symbol_pairs, @symbol_dependents, @ancestry_dependents)
346
- else
347
- Incremental.affected(unstable, @dependents)
348
- end
408
+ base = if symbol_pairs.any? || unstable.any? { |f| @ancestry_dependents[f] }
409
+ Incremental.affected_with_symbols(unstable, symbol_pairs, @symbol_dependents, @ancestry_dependents)
410
+ else
411
+ Incremental.affected(unstable, @dependents)
412
+ end
413
+ base | singleton_rewrite_affected(unstable)
349
414
  end
350
415
 
351
416
  # ADR-67 WD6c lift — the fresh whole-project inferred-param table for this recheck ({} when the gate
@@ -440,7 +505,7 @@ module Rigor
440
505
  def whole_project_closure(changed, added)
441
506
  (@analyzed.to_set | changed | added).freeze
442
507
  end
443
- private :gated_closure, :whole_project_closure
508
+ private :gated_closure, :whole_project_closure, :singleton_rewrite_affected, :singleton_rewritten_constants
444
509
 
445
510
  # The current project file set (cheap directory expansion, no analysis), used to detect files added /
446
511
  # removed since the last run. Also where an editor buffer's binding is re-spelled onto the set, since
@@ -463,8 +528,7 @@ module Rigor
463
528
  # ADR-67 WD6c lift — seed the subset run from the baseline's own table so the verification engine
464
529
  # exercises the exact seeds the served cache entries were computed under (and skips a re-collect).
465
530
  runner = build_runner(analyze_only: affected, param_inferred_types: @param_table,
466
- restored_run_level_rows: @run_level_rows,
467
- served_per_file_diagnostics: @cache.slice(*reused))
531
+ restored_run_level_rows: @run_level_rows, **served_state(reused))
468
532
  run_runner(runner).diagnostics
469
533
  end
470
534
 
@@ -480,6 +544,7 @@ module Rigor
480
544
  # to. Every soundness gate below — the fingerprint, the ADR-88 fact surface — is unchanged, so the
481
545
  # decision to reuse is made identically whether or not the result is saved.
482
546
  def run_incremental(snapshot:, fingerprint:, persist: true)
547
+ @last_recheck = nil # #1526 — set only by a warm recheck, see {#last_recheck}
483
548
  # ADR-87 WD1 — install the per-run digest table + recording instant + strict flag for the whole
484
549
  # invocation so change-detection's stat-then-digest freshness (`#pack_digest` / `#stat_fresh?`) honours
485
550
  # `cache.validation: digest` (and `RIGOR_STRICT_VALIDATION`, which the env-only path already sees) and
@@ -507,8 +572,8 @@ module Rigor
507
572
  result = recheck
508
573
  adopt_plugin_fact_fingerprint
509
574
  fact_reusable = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
510
- if fact_reusable && effects_reusable && !source_rbs_unverified?(result)
511
- diagnostics = result.diagnostics
575
+ if fact_reusable && effects_reusable && !result.rebaselined && !source_rbs_unverified?(result)
576
+ diagnostics = keep_recheck(result)
512
577
  warm = true
513
578
  # ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
514
579
  # snapshot it restored, so skip the unconditional rewrite (209ms + 2 MB on gitlab per null recheck).
@@ -522,7 +587,8 @@ module Rigor
522
587
  # digest (from the recheck runner) is unchanged by the re-analysis, so it is kept for the save.
523
588
  # Only a genuine fact-surface reason sets the reporting flag the CLI banner reads.
524
589
  @fact_surface_invalidated = true unless fact_reusable
525
- diagnostics = baseline
590
+ # #1898 — a recheck that already re-baselined over stale plugin effect facts is that re-analysis.
591
+ diagnostics = result.rebaselined ? result.diagnostics : baseline
526
592
  warm = false
527
593
  skip_save = false
528
594
  end
@@ -539,9 +605,36 @@ module Rigor
539
605
 
540
606
  # ADR-88 WD1 — reporting hooks the CLI reads after {#run_incremental}. `fact_surface_invalidated?` is true
541
607
  # when a valid snapshot was dropped for a fact-surface reason (a plugin sig/catalog edit, or an opaque
542
- # plugin) rather than a cold miss. `opaque_plugin_ids` names the contributing-but-surfaceless plugins that
543
- # force a full run every invocation.
544
- attr_reader :opaque_plugin_ids
608
+ # plugin) rather than a cold miss. `opaque_reasons` maps each plugin that forces a full run every invocation
609
+ # to its {PluginFactFingerprint::OpaqueReason}.
610
+ attr_reader :opaque_reasons
611
+
612
+ # #1526 — the {Recheck} of the last {#run_incremental}, or nil unless that run answered warm. A baseline
613
+ # (cold, or a re-baseline after a fact-surface, effects or source-RBS reason) has no recheck to report.
614
+ # The diagnostics are not kept: a long-lived session (the LSP) would otherwise retain every merged list.
615
+ attr_reader :last_recheck
616
+
617
+ # Records the warm recheck's sets for {#last_recheck} and hands back its diagnostics.
618
+ def keep_recheck(result)
619
+ @last_recheck = result.with(diagnostics: [])
620
+ result.diagnostics
621
+ end
622
+ private :keep_recheck
623
+
624
+ # The opaque plugin ids, sorted (the keys of {#opaque_reasons}).
625
+ def opaque_plugin_ids
626
+ @opaque_reasons.keys
627
+ end
628
+
629
+ # True when the fact store could not be read, so no single plugin is named (see {PluginFactFingerprint::Result}).
630
+ def fact_store_unreadable?
631
+ @fact_store_unreadable
632
+ end
633
+
634
+ # True when the fact surface forces a full run: some plugin is opaque, or the fact store is unreadable.
635
+ def opaque_fact_surface?
636
+ !@opaque_reasons.empty? || @fact_store_unreadable
637
+ end
545
638
 
546
639
  def fact_surface_invalidated?
547
640
  @fact_surface_invalidated
@@ -566,7 +659,8 @@ module Rigor
566
659
  registry = prepared_plugin_registry
567
660
  fact = compute_plugin_fact_fingerprint(registry)
568
661
  @plugin_fact_digest = fact.digest
569
- @opaque_plugin_ids = fact.opaque_plugin_ids
662
+ @opaque_reasons = fact.opaque_reasons
663
+ @fact_store_unreadable = fact.fact_store_unreadable
570
664
  @plugin_fact_reusable = fact
571
665
  @seed_bundles = source_rbs_gate.verify(registry, @seed_bundles)
572
666
  end
@@ -596,10 +690,10 @@ module Rigor
596
690
 
597
691
  # ADR-88 WD1 — the plugin fact-surface fingerprint for this invocation. For a SEQUENTIAL run it is read
598
692
  # post-hoc from the analysis runner's already-prepared registry (no second `#prepare`, producers already
599
- # validated during analysis). A POOLED run's main process skips `#prepare`, so its registry carries no
600
- # published facts — there it falls back to the always-sequential probe. Both paths compute the identical
601
- # digest for a given fact surface, so the reuse decision is worker-count-independent (the parity spec
602
- # asserts this).
693
+ # validated during analysis). A POOLED run's main process does not run `#prepare` on it (its workers do),
694
+ # so its registry carries no published facts — there it falls back to the always-sequential probe. Both
695
+ # paths compute the identical digest for a given fact surface, so the reuse decision is worker-count-independent
696
+ # (the parity spec asserts this).
603
697
  def compute_plugin_fact_fingerprint(registry = prepared_plugin_registry)
604
698
  PluginFactFingerprint.from_registry(registry)
605
699
  end
@@ -710,7 +804,8 @@ module Rigor
710
804
  return nil if fingerprint.nil? || @cache_store.nil? || @cache_store.read_only? || !@buffer.nil?
711
805
  # Known before the run: none of these can be written afterwards, so the stamp and the recomputed
712
806
  # fingerprint would be spent for nothing.
713
- return nil unless @workers.zero? && !@configuration.effects_enabled?
807
+ # `WriteGuard.start` would also refuse (it recomputes the fingerprint without `explain:`); say so here.
808
+ return nil unless @workers.zero? && !@configuration.effects_enabled? && !@explain
714
809
 
715
810
  IncrementalRunSlot::WriteGuard.start(
716
811
  configuration: @configuration, roots: @paths || @configuration.paths, cache_root: @cache_store.root,
@@ -724,7 +819,7 @@ module Rigor
724
819
  # cold, not the warm run the probe stands in for.
725
820
  def run_slot_writable?
726
821
  !@cache_store.nil? && !@cache_store.read_only? && @buffer.nil? && @workers.zero? &&
727
- !@configuration.effects_enabled? && @opaque_plugin_ids.empty? && !@last_runner.nil?
822
+ !@configuration.effects_enabled? && !@explain && !opaque_fact_surface? && !@last_runner.nil?
728
823
  end
729
824
 
730
825
  def start_chain(rows)
@@ -817,6 +912,7 @@ module Rigor
817
912
  # a partial re-collection cannot be closed into a whole-project fixpoint.
818
913
  @effects_identity = payload.effects_identity
819
914
  @effect_collections = effect_collections_reusable?(payload) ? payload.effect_collections.dup : {}
915
+ @effect_plugin_digest = payload.effect_plugin_digest
820
916
  @symbol_dependents = Incremental.invert_symbols(@symbol_sources)
821
917
  @ancestry_dependents = Incremental.invert(@ancestry_sources)
822
918
  @negative_dependents = Incremental.invert(@missing)
@@ -845,7 +941,8 @@ module Rigor
845
941
  param_table: marshal_safe_param_table,
846
942
  effect_collections: marshal_safe_effect_collections,
847
943
  effects_identity: @effects_identity,
848
- run_level_rows: @run_level_rows
944
+ run_level_rows: @run_level_rows,
945
+ effect_plugin_digest: @effect_plugin_digest
849
946
  )
850
947
  end
851
948
 
@@ -857,6 +954,7 @@ module Rigor
857
954
  def reset_effect_state
858
955
  @effect_collections = {}
859
956
  @effects_identity = nil
957
+ @effect_plugin_digest = nil
860
958
  end
861
959
 
862
960
  # The state this session carries ACROSS processes in the snapshot rather than deriving each run: the
@@ -947,6 +1045,7 @@ module Rigor
947
1045
  # multiple edits: the analyzed set gets fresh diagnostics + digests + dependency edges, removed files
948
1046
  # are evicted from every map, and the analyzed-file list advances to `current`.
949
1047
  def absorb(runner, current, analyze_set, removed)
1048
+ @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
950
1049
  removed.each { |path| forget(path) }
951
1050
  @analyzed = current
952
1051
  # ADR-85 WD2 — the recheck's discovery folded the restored bundles and refreshed them (changed files
@@ -980,6 +1079,7 @@ module Rigor
980
1079
  @effect_collections.reject! { |path, _| analyzed.include?(path) }
981
1080
  @effect_collections.merge!(runner.effect_collections_by_path)
982
1081
  @effects_identity = current_effects_identity
1082
+ @effect_plugin_digest = effect_plugin_digest_of(runner)
983
1083
  end
984
1084
 
985
1085
  # ADR-89 WD2 — replace the behavioural summaries of every re-analyzed file with THIS run's harvest: drop
@@ -1075,7 +1175,8 @@ module Rigor
1075
1175
  # appeared `"ClassName#method"` to the negative-dependency key it would satisfy (`toplevel:foo` for a
1076
1176
  # top-level def, `method:C#m` otherwise), then unions the recorded negative-dependents of those keys.
1077
1177
  # Issue #1120 — plus the consumers of any refinement name the edit moved ({#refinement_affected}). Issue #1367 —
1078
- # and every file whose last result holds a `global.*` diagnostic ({#global_write_reporters}).
1178
+ # and every file whose last result holds a `global.*` diagnostic ({#global_write_reporters}). Issue #1459 — and
1179
+ # the consumers of any method name a file started or stopped defining ({#defined_name_affected}).
1079
1180
  def negative_affected(changed, removed, new_fingerprints, new_class_decls, new_constant_decls, summary = nil)
1080
1181
  appeared_methods = Incremental.appeared_symbols(changed, @symbol_fingerprints, new_fingerprints)
1081
1182
  # Issue #639 — a class DISAPPEARING satisfies the `class:` kind too, now that a resolved bare
@@ -1093,7 +1194,7 @@ module Rigor
1093
1194
  keys.concat(class_move_keys(moved_classes))
1094
1195
  keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
1095
1196
  Incremental.negative_closure(keys, @negative_dependents) | refinement_affected(changed + removed, summary) |
1096
- global_write_reporters
1197
+ defined_name_affected(changed + removed, summary) | global_write_reporters
1097
1198
  end
1098
1199
 
1099
1200
  # The negative keys a class-declaration diff fires: `class:<last segment>` for a class name, and (issue #1820)
@@ -1121,14 +1222,30 @@ module Rigor
1121
1222
  Incremental.negative_closure(names.map { |name| "refinement:#{name}" }, @negative_dependents)
1122
1223
  end
1123
1224
 
1225
+ # Issue #1459 — the consumers whose answer asked whether the program defines a method name anywhere, for each
1226
+ # name a file in `paths` started or stopped defining. The before-state is each file's seed bundle, the
1227
+ # after-state the scan's per-file name sets. Unlike the appeared-symbol diff, it sees a name an `attr_*`,
1228
+ # `alias` or `define_method` adds, and one that vanishes, which brings a declined reading back.
1229
+ def defined_name_affected(paths, summary)
1230
+ after = (summary && summary[:defined_method_names]) || {}
1231
+ before = paths.to_h do |path|
1232
+ bundle = @seed_bundles[path]
1233
+ [path, bundle && Inference::ScopeIndexer.defined_method_names(bundle)]
1234
+ end
1235
+ names = Incremental.changed_defined_names(paths, before, after)
1236
+ return Set.new if names.empty?
1237
+
1238
+ Incremental.negative_closure(names.map { |name| "defines:#{name}" }, @negative_dependents)
1239
+ end
1240
+
1124
1241
  # The qualified class/module names declared in the pre-parsed `index` (shared with
1125
1242
  # {#symbol_fingerprints_from_index}, so the changed set is parsed once). `{ path => Set<class name> }`.
1126
1243
  def class_declarations_from_index(index)
1127
1244
  return {} if index.nil?
1128
1245
 
1129
1246
  result = Hash.new { |hash, key| hash[key] = Set.new }
1130
- index[:class_sources].each do |class_name, files|
1131
- files.each { |file| result[file] << class_name }
1247
+ [index[:class_sources], index[:singleton_class_sources] || {}].each do |sources|
1248
+ sources.each { |class_name, files| files.each { |file| result[file] << class_name } }
1132
1249
  end
1133
1250
  Incremental.add_superclass_edges(result, index[:class_sources], index[:superclasses])
1134
1251
  result.transform_values(&:freeze).freeze
@@ -1269,10 +1386,60 @@ module Rigor
1269
1386
  Runner.new(
1270
1387
  configuration: @configuration, cache_store: @cache_store, environment: @environment,
1271
1388
  plugin_requirer: @plugin_requirer, seed_bundles: @seed_bundles, collect_seed_bundles: true,
1272
- workers: @workers, buffer: @buffer, **
1389
+ workers: @workers, buffer: @buffer, explain: @explain, on_file_indexed: @on_file_indexed, **
1273
1390
  )
1274
1391
  end
1275
1392
 
1393
+ # The forced targets present in `current`, and the recheck's analyse set with them joined to the closure.
1394
+ def forced_analyze_set(affected, current)
1395
+ forced = @force_targets & current.to_set
1396
+ [forced, (affected | forced) & current]
1397
+ end
1398
+
1399
+ # What the session serves a narrowed run for the files it does not analyse: their cached per-file rows (#1524),
1400
+ # and (#1898) their effect collections, so the run closes its effect graph over the whole project.
1401
+ def served_state(reused)
1402
+ { served_per_file_diagnostics: @cache.slice(*reused),
1403
+ served_effect_collections: @effect_collections.slice(*reused) }
1404
+ end
1405
+
1406
+ # Issue #1898 — the plugin effect-fact digest a run's collections were stamped under. A plugin writes its
1407
+ # attributions and edges into a file's collection when the file is collected, and what it contributes can
1408
+ # follow project state no file digest sees (rigor-activejob reads `config.active_job.queue_adapter`), so a
1409
+ # served collection is current only while this digest is. nil when collection is off.
1410
+ def effect_plugin_digest_of(runner)
1411
+ @configuration.effects_enabled? ? runner.effect_plugin_facts.digest : nil
1412
+ end
1413
+
1414
+ # Whether `runner` judged envelopes over served collections stamped under a different plugin effect-fact
1415
+ # digest, or an unknown one. The comparison waits for the run because the plugins load inside it.
1416
+ def served_effects_stale?(runner, reused)
1417
+ return false if reused.empty? || @effect_collections.empty?
1418
+
1419
+ effect_plugin_digest_of(runner) != @effect_plugin_digest
1420
+ end
1421
+
1422
+ # The answer {#recheck} gives when {#served_effects_stale?}: a full baseline, reported as a recheck that
1423
+ # re-analysed every file. {#run_incremental} reads `rebaselined` so it neither runs a second baseline
1424
+ # nor reports the run as warm.
1425
+ def rebaseline_recheck(changed, added, removed)
1426
+ diagnostics = baseline
1427
+ recheck_result(diagnostics, [changed, added, removed],
1428
+ affected: @analyzed.to_set, reused: Set.new, runner: @last_runner, rebaselined: true)
1429
+ end
1430
+
1431
+ # A {Recheck} over `deltas` (`[changed, added, removed]`), counting `runner`'s template units.
1432
+ def recheck_result(diagnostics, deltas, affected:, reused:, runner:, rebaselined: false)
1433
+ changed, added, removed = deltas.map(&:to_set)
1434
+ Recheck.new(diagnostics: diagnostics, changed: changed, added: added, removed: removed, affected: affected,
1435
+ reused: reused, templates: runner.template_unit_paths.size, rebaselined: rebaselined)
1436
+ end
1437
+
1438
+ # A recheck that forces targets analyses in this process, so the index callback reaches them.
1439
+ def forced_target_options(forced)
1440
+ forced.empty? ? {} : { workers: 0 }
1441
+ end
1442
+
1276
1443
  # Run the runner over the session's explicit paths (or, when none were given, the configuration's
1277
1444
  # `paths:` via `Runner#run`'s default).
1278
1445
  def run_runner(runner)
@@ -14,10 +14,10 @@ module Rigor
14
14
  module_function
15
15
 
16
16
  # The `.rb` files an ordinary (non-buffer) check over `paths` would analyze.
17
- def ruby_files(paths, exclude_patterns)
17
+ def ruby_files(paths, exclude_patterns, base: Dir.pwd)
18
18
  Array(paths).flat_map do |path|
19
19
  if File.directory?(path)
20
- directory_files(path, exclude_patterns)
20
+ directory_files(path, exclude_patterns, base: base)
21
21
  elsif File.file?(path) && path.end_with?(".rb")
22
22
  [path]
23
23
  else
@@ -26,16 +26,72 @@ module Rigor
26
26
  end
27
27
  end
28
28
 
29
- def directory_files(dir, exclude_patterns)
30
- reject_excluded(Dir.glob(File.join(dir, RUBY_GLOB)), exclude_patterns)
29
+ def directory_files(dir, exclude_patterns, base: Dir.pwd)
30
+ reject_excluded(Dir.glob(File.join(dir, RUBY_GLOB)), exclude_patterns, base: base)
31
31
  end
32
32
 
33
33
  # `exclude_patterns` are matched with `File.fnmatch?` WITHOUT `FNM_PATHNAME`, so `**`/`*` span separators
34
34
  # (the patterns behave like substring globs) — the exact semantics the Runner's directory glob applies.
35
- def reject_excluded(file_list, exclude_patterns)
35
+ #
36
+ # #1576 — a RELATIVE pattern (a leading `./` is ignored) is matched against the file's path relative to
37
+ # `base` (the config file's directory, or the working directory with no config), so the same file is
38
+ # excluded whether `paths:` (absolute) or an argument (relative) named it.
39
+ #
40
+ # Two spellings are tried. The LOGICAL one (the path as given, against the logical base) counts only when
41
+ # the file lies inside the base; it is what lets a symlinked `lib` match `lib/gen/**`. The REALPATH one
42
+ # (realpath of the file against realpath of the base) is the only one used for a file outside the base, and
43
+ # it also catches a base or file spelled through a different symlink root (macOS `/tmp` vs `/private/tmp`).
44
+ # Neither shows a directory name above the base: a file outside it comes out as `../…`, so `**/tmp/**`
45
+ # cannot exclude a project merely located under a `tmp/`, while `**/vendor/bundle/**` still reaches
46
+ # `../other/vendor/bundle/x.rb`. The pattern is also tried against `/<relative>`, because without
47
+ # `FNM_PATHNAME` the unanchored `**/tmp/**` needs a leading `/` to match a top-level `tmp/x.rb` (and `*`
48
+ # never matches a leading `.`, as in `../other`). A pattern starting with `.` therefore also matches files
49
+ # outside the base. An ABSOLUTE pattern is matched only against the absolute path (logical, then real).
50
+ def reject_excluded(file_list, exclude_patterns, base: Dir.pwd)
36
51
  return file_list if exclude_patterns.empty?
37
52
 
38
- file_list.reject { |path| exclude_patterns.any? { |pattern| File.fnmatch?(pattern, path) } }
53
+ absolute_patterns, relative_patterns = exclude_patterns.partition { |pattern| pattern.start_with?("/") }
54
+ relative_patterns = relative_patterns.map { |pattern| pattern.sub(%r{\A(?:\./+)+}, "") }
55
+ logical_base = File.absolute_path(base)
56
+ context = { cwd: Dir.pwd, base: logical_base, real_base: real_directory(logical_base), real_dirs: {} }
57
+ file_list.reject { |path| excluded?(path, absolute_patterns, relative_patterns, context) }
58
+ end
59
+
60
+ def excluded?(path, absolute_patterns, relative_patterns, context)
61
+ logical = File.absolute_path(path, context.fetch(:cwd))
62
+ base = context.fetch(:base)
63
+ inside = logical.start_with?("#{base}/")
64
+ return true if inside && matches_relative?(relative_patterns, logical.byteslice((base.bytesize + 1)..))
65
+ return true if absolute_patterns.any? { |pattern| File.fnmatch?(pattern, logical) }
66
+
67
+ real = real_file(logical, context.fetch(:real_dirs))
68
+ return false if real == logical && inside && context.fetch(:real_base) == base
69
+
70
+ matches_relative?(relative_patterns, relative_path(real, context.fetch(:real_base))) ||
71
+ (real != logical && absolute_patterns.any? { |pattern| File.fnmatch?(pattern, real) })
72
+ end
73
+
74
+ def relative_path(absolute, base)
75
+ return absolute.byteslice((base.bytesize + 1)..) if absolute.start_with?("#{base}/")
76
+
77
+ Pathname.new(absolute).relative_path_from(Pathname.new(base)).to_s
78
+ end
79
+
80
+ def matches_relative?(patterns, relative)
81
+ candidates = [relative, "/#{relative}"]
82
+ patterns.any? { |pattern| candidates.any? { |candidate| File.fnmatch?(pattern, candidate) } }
83
+ end
84
+
85
+ def real_directory(dir)
86
+ File.realpath(dir)
87
+ rescue SystemCallError
88
+ File.absolute_path(dir)
89
+ end
90
+
91
+ def real_file(absolute, real_dirs)
92
+ dir = File.dirname(absolute)
93
+ real_dir = real_dirs[dir] ||= real_directory(dir)
94
+ File.join(real_dir, File.basename(absolute))
39
95
  end
40
96
  end
41
97
  end