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
@@ -38,7 +38,7 @@ 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
+ Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused, :templates) do
42
42
  # A recheck that changed, added, and removed nothing — the session state is byte-equivalent to the
43
43
  # snapshot that was restored, so persisting it again would only rewrite identical bytes.
44
44
  def no_change?
@@ -68,9 +68,14 @@ module Rigor
68
68
  # `--verify-incremental` gate use) keeps the sequential recording path bit-for-bit unchanged. The fork
69
69
  # pool records each worker's cross-file reads and marshals them back (PoolCoordinator), so the
70
70
  # 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)
71
+ def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
72
+ workers: 0, buffer: nil, explain: false, on_file_indexed: nil)
73
73
  @configuration = configuration
74
+ # ADR-113 WD2 — `rigor lens`: {#force_targets=} names files a {#recheck} analyses whether or not they changed,
75
+ # in this process, so `on_file_indexed` (see {Runner}) sees their seeded index. Neither moves a diagnostic:
76
+ # a forced file re-analyses under the same seeds its cached rows were computed with.
77
+ @force_targets = Set.new
78
+ @on_file_indexed = on_file_indexed
74
79
  @paths = paths
75
80
  # Editor mode option B (#146) — the in-flight buffer, threaded into every internal Runner so the
76
81
  # pre-passes and the closure re-analysis read the editor's bytes at the logical path. A session
@@ -81,6 +86,7 @@ module Rigor
81
86
  @cache_store = cache_store
82
87
  @plugin_requirer = plugin_requirer
83
88
  @workers = workers
89
+ @explain = explain # #1533: threaded into every Runner; keys the snapshot, and no slot is written
84
90
  # ADR-85 WD2 — per-file discovery seed bundles keyed by logical path. A cold baseline builds them; a
85
91
  # warm recheck folds them (re-walking only changed files) and refreshes the set. Ride the snapshot.
86
92
  @seed_bundles = {}
@@ -122,12 +128,31 @@ module Rigor
122
128
  # banner + `--cache-stats`) reads after `#run_incremental`. `@last_runner` is the analysis runner the
123
129
  # post-hoc fingerprint reads from; `@plugin_fact_reusable` is the computed decision object.
124
130
  @plugin_fact_digest = nil
125
- @opaque_plugin_ids = [].freeze
131
+ @opaque_reasons = {}.freeze
132
+ @fact_store_unreadable = false
126
133
  @fact_surface_invalidated = false
127
134
  @last_runner = nil
128
135
  @plugin_fact_reusable = nil
129
136
  end
130
137
 
138
+ # ADR-113 WD2 — the paths (as {#project_files} spells them) every later {#recheck} analyses in this process.
139
+ def force_targets=(paths)
140
+ @force_targets = paths.to_set
141
+ end
142
+
143
+ # The project files a run of this session analyses, as the runner expands the configured paths.
144
+ def project_files
145
+ current_files
146
+ end
147
+
148
+ # The per-file discovery bundles of the last run, keyed by path (ADR-85 WD2).
149
+ attr_reader :seed_bundles
150
+
151
+ # The project files `path`'s analysis read from at the last run that analysed it (ADR-46 slice 1).
152
+ def dependency_sources(path)
153
+ @sources.fetch(path, Set.new)
154
+ end
155
+
131
156
  # The project files analyzed at the last baseline / recheck — the set a verify pass partitions and the
132
157
  # merge subtracts the affected closure from.
133
158
  def analyzed_files
@@ -206,13 +231,13 @@ module Rigor
206
231
  fresh_params = fresh_param_table(current, changed, added, removed)
207
232
  param_files, param_pairs = param_seed_invalidation(fresh_params)
208
233
  affected = affected_closure(changed, added, removed, param_files, param_pairs)
209
- analyze_set = affected & current
210
- reused = (current & previous) - affected.to_a
234
+ forced, analyze_set = forced_analyze_set(affected, current)
235
+ reused = (current & previous) - analyze_set.to_a
211
236
  # The freshly collected table is handed to the runner so the run seeds from the SAME table the diff
212
237
  # was decided on (and the collector runs once per recheck, not twice). The reused files' cached rows
213
238
  # go in too, so the runner lists them in the full run's file order (#1524).
214
239
  runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
215
- param_inferred_types: fresh_params,
240
+ param_inferred_types: fresh_params, **forced_target_options(forced),
216
241
  restored_run_level_rows: @run_level_rows,
217
242
  served_per_file_diagnostics: @cache.slice(*reused))
218
243
  merged = run_runner(runner).diagnostics
@@ -220,7 +245,8 @@ module Rigor
220
245
  absorb(runner, current, analyze_set, removed)
221
246
  @param_table = fresh_params
222
247
  Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
223
- removed: removed.to_set, affected: affected, reused: reused.to_set)
248
+ removed: removed.to_set, affected: affected, reused: reused.to_set,
249
+ templates: runner.template_unit_paths.size)
224
250
  end
225
251
 
226
252
  # Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
@@ -480,6 +506,7 @@ module Rigor
480
506
  # to. Every soundness gate below — the fingerprint, the ADR-88 fact surface — is unchanged, so the
481
507
  # decision to reuse is made identically whether or not the result is saved.
482
508
  def run_incremental(snapshot:, fingerprint:, persist: true)
509
+ @last_recheck = nil # #1526 — set only by a warm recheck, see {#last_recheck}
483
510
  # ADR-87 WD1 — install the per-run digest table + recording instant + strict flag for the whole
484
511
  # invocation so change-detection's stat-then-digest freshness (`#pack_digest` / `#stat_fresh?`) honours
485
512
  # `cache.validation: digest` (and `RIGOR_STRICT_VALIDATION`, which the env-only path already sees) and
@@ -508,7 +535,7 @@ module Rigor
508
535
  adopt_plugin_fact_fingerprint
509
536
  fact_reusable = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
510
537
  if fact_reusable && effects_reusable && !source_rbs_unverified?(result)
511
- diagnostics = result.diagnostics
538
+ diagnostics = keep_recheck(result)
512
539
  warm = true
513
540
  # ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
514
541
  # snapshot it restored, so skip the unconditional rewrite (209ms + 2 MB on gitlab per null recheck).
@@ -539,9 +566,36 @@ module Rigor
539
566
 
540
567
  # ADR-88 WD1 — reporting hooks the CLI reads after {#run_incremental}. `fact_surface_invalidated?` is true
541
568
  # 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
569
+ # plugin) rather than a cold miss. `opaque_reasons` maps each plugin that forces a full run every invocation
570
+ # to its {PluginFactFingerprint::OpaqueReason}.
571
+ attr_reader :opaque_reasons
572
+
573
+ # #1526 — the {Recheck} of the last {#run_incremental}, or nil unless that run answered warm. A baseline
574
+ # (cold, or a re-baseline after a fact-surface, effects or source-RBS reason) has no recheck to report.
575
+ # The diagnostics are not kept: a long-lived session (the LSP) would otherwise retain every merged list.
576
+ attr_reader :last_recheck
577
+
578
+ # Records the warm recheck's sets for {#last_recheck} and hands back its diagnostics.
579
+ def keep_recheck(result)
580
+ @last_recheck = result.with(diagnostics: [])
581
+ result.diagnostics
582
+ end
583
+ private :keep_recheck
584
+
585
+ # The opaque plugin ids, sorted (the keys of {#opaque_reasons}).
586
+ def opaque_plugin_ids
587
+ @opaque_reasons.keys
588
+ end
589
+
590
+ # True when the fact store could not be read, so no single plugin is named (see {PluginFactFingerprint::Result}).
591
+ def fact_store_unreadable?
592
+ @fact_store_unreadable
593
+ end
594
+
595
+ # True when the fact surface forces a full run: some plugin is opaque, or the fact store is unreadable.
596
+ def opaque_fact_surface?
597
+ !@opaque_reasons.empty? || @fact_store_unreadable
598
+ end
545
599
 
546
600
  def fact_surface_invalidated?
547
601
  @fact_surface_invalidated
@@ -566,7 +620,8 @@ module Rigor
566
620
  registry = prepared_plugin_registry
567
621
  fact = compute_plugin_fact_fingerprint(registry)
568
622
  @plugin_fact_digest = fact.digest
569
- @opaque_plugin_ids = fact.opaque_plugin_ids
623
+ @opaque_reasons = fact.opaque_reasons
624
+ @fact_store_unreadable = fact.fact_store_unreadable
570
625
  @plugin_fact_reusable = fact
571
626
  @seed_bundles = source_rbs_gate.verify(registry, @seed_bundles)
572
627
  end
@@ -596,10 +651,10 @@ module Rigor
596
651
 
597
652
  # ADR-88 WD1 — the plugin fact-surface fingerprint for this invocation. For a SEQUENTIAL run it is read
598
653
  # 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).
654
+ # validated during analysis). A POOLED run's main process does not run `#prepare` on it (its workers do),
655
+ # so its registry carries no published facts — there it falls back to the always-sequential probe. Both
656
+ # paths compute the identical digest for a given fact surface, so the reuse decision is worker-count-independent
657
+ # (the parity spec asserts this).
603
658
  def compute_plugin_fact_fingerprint(registry = prepared_plugin_registry)
604
659
  PluginFactFingerprint.from_registry(registry)
605
660
  end
@@ -710,7 +765,8 @@ module Rigor
710
765
  return nil if fingerprint.nil? || @cache_store.nil? || @cache_store.read_only? || !@buffer.nil?
711
766
  # Known before the run: none of these can be written afterwards, so the stamp and the recomputed
712
767
  # fingerprint would be spent for nothing.
713
- return nil unless @workers.zero? && !@configuration.effects_enabled?
768
+ # `WriteGuard.start` would also refuse (it recomputes the fingerprint without `explain:`); say so here.
769
+ return nil unless @workers.zero? && !@configuration.effects_enabled? && !@explain
714
770
 
715
771
  IncrementalRunSlot::WriteGuard.start(
716
772
  configuration: @configuration, roots: @paths || @configuration.paths, cache_root: @cache_store.root,
@@ -724,7 +780,7 @@ module Rigor
724
780
  # cold, not the warm run the probe stands in for.
725
781
  def run_slot_writable?
726
782
  !@cache_store.nil? && !@cache_store.read_only? && @buffer.nil? && @workers.zero? &&
727
- !@configuration.effects_enabled? && @opaque_plugin_ids.empty? && !@last_runner.nil?
783
+ !@configuration.effects_enabled? && !@explain && !opaque_fact_surface? && !@last_runner.nil?
728
784
  end
729
785
 
730
786
  def start_chain(rows)
@@ -1127,8 +1183,8 @@ module Rigor
1127
1183
  return {} if index.nil?
1128
1184
 
1129
1185
  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 }
1186
+ [index[:class_sources], index[:singleton_class_sources] || {}].each do |sources|
1187
+ sources.each { |class_name, files| files.each { |file| result[file] << class_name } }
1132
1188
  end
1133
1189
  Incremental.add_superclass_edges(result, index[:class_sources], index[:superclasses])
1134
1190
  result.transform_values(&:freeze).freeze
@@ -1269,10 +1325,21 @@ module Rigor
1269
1325
  Runner.new(
1270
1326
  configuration: @configuration, cache_store: @cache_store, environment: @environment,
1271
1327
  plugin_requirer: @plugin_requirer, seed_bundles: @seed_bundles, collect_seed_bundles: true,
1272
- workers: @workers, buffer: @buffer, **
1328
+ workers: @workers, buffer: @buffer, explain: @explain, on_file_indexed: @on_file_indexed, **
1273
1329
  )
1274
1330
  end
1275
1331
 
1332
+ # The forced targets present in `current`, and the recheck's analyse set with them joined to the closure.
1333
+ def forced_analyze_set(affected, current)
1334
+ forced = @force_targets & current.to_set
1335
+ [forced, (affected | forced) & current]
1336
+ end
1337
+
1338
+ # A recheck that forces targets analyses in this process, so the index callback reaches them.
1339
+ def forced_target_options(forced)
1340
+ forced.empty? ? {} : { workers: 0 }
1341
+ end
1342
+
1276
1343
  # Run the runner over the session's explicit paths (or, when none were given, the configuration's
1277
1344
  # `paths:` via `Runner#run`'s default).
1278
1345
  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
@@ -36,12 +36,55 @@ module Rigor
36
36
  # The probe is ALWAYS sequential and independent of the analysis's pool mode, so the invalidation decision
37
37
  # is identical whether the recheck ran pooled or sequential (the pooled-vs-sequential parity requirement).
38
38
  class PluginFactFingerprint
39
- # `digest` is nil when no plugins are loaded — the fast path (nothing to fingerprint), which reuses
40
- # freely. `opaque_plugin_ids` names contributing-but-surfaceless plugins (see the class doc); when
41
- # non-empty the snapshot is never reused.
42
- Result = Data.define(:digest, :opaque_plugin_ids) do
39
+ # Why one plugin is opaque to the fingerprint. `kind` is one of:
40
+ # :no_surface the plugin contributes types and declares no fact, producer or hook;
41
+ # :undigestible a value it declares or publishes cannot be digested (a Proc, an IO, ...);
42
+ # :raised the hook or a producer raised while its value was being read.
43
+ # `input` names the input: `"producer worker_index"`, `"fact callback"`, `"hook incremental_state_fingerprint"`.
44
+ # `class_name` is the class of the value that could not be digested, or of the exception raised; an anonymous
45
+ # class reads "anonymous Class", and nil means the class itself could not be read.
46
+ OpaqueReason = Data.define(:kind, :input, :class_name) do
47
+ # The clause a note states about plugin `id`. The note groups :no_surface plugins itself, so this is only
48
+ # called for :undigestible and :raised reasons.
49
+ def describe(id)
50
+ return "plugin #{id}'s #{input} raised #{class_name}" if kind == :raised
51
+
52
+ "plugin #{id}'s #{input} has a value the incremental fingerprint cannot digest#{parenthesised_class}"
53
+ end
54
+
55
+ # The same verdict without the plugin id, for a `--cache-stats` line that already names it.
56
+ def summary
57
+ return "no incremental fingerprint surface" if kind == :no_surface
58
+ return "#{input} raised #{class_name}" if kind == :raised
59
+
60
+ "#{input} has a value the incremental fingerprint cannot digest#{parenthesised_class}"
61
+ end
62
+
63
+ private
64
+
65
+ def parenthesised_class
66
+ return "" if class_name.nil?
67
+
68
+ " (#{article(class_name)} #{class_name})"
69
+ end
70
+
71
+ def article(word)
72
+ word.match?(/\A[aeiou]/i) ? "an" : "a"
73
+ end
74
+ end
75
+
76
+ # `digest` is nil when no plugins are loaded — the fast path (nothing to fingerprint), which reuses freely.
77
+ # `opaque_reasons` maps each opaque plugin id to its {OpaqueReason}, sorted by id. `fact_store_unreadable` is
78
+ # true when the fact store could not be read at all: no single plugin is to blame, so no reason names one.
79
+ # Either makes the snapshot un-reusable.
80
+ Result = Data.define(:digest, :opaque_reasons, :fact_store_unreadable) do
81
+ # The opaque plugin ids, sorted (the keys of {#opaque_reasons}).
82
+ def opaque_plugin_ids
83
+ opaque_reasons.keys
84
+ end
85
+
43
86
  def opaque?
44
- !opaque_plugin_ids.empty?
87
+ fact_store_unreadable || !opaque_reasons.empty?
45
88
  end
46
89
 
47
90
  # A warm recheck may reuse the snapshot only when this run is not opaque and its fact-surface digest
@@ -97,16 +140,20 @@ module Rigor
97
140
  end
98
141
 
99
142
  def digest_registry(registry)
100
- return Result.new(digest: nil, opaque_plugin_ids: [].freeze) if registry.nil? || registry.empty?
143
+ empty = registry.nil? || registry.empty?
144
+ return Result.new(digest: nil, opaque_reasons: {}.freeze, fact_store_unreadable: false) if empty
101
145
 
102
- opaque = []
103
- facts_by_plugin = facts_by_plugin(registry, opaque)
104
- parts = fact_parts(facts_by_plugin)
105
- registry.plugins.each { |plugin| collect_plugin_parts(plugin, facts_by_plugin, parts, opaque) }
106
- # Sort the parts so the digest is independent of plugin registration / iteration order.
146
+ reasons = {}
147
+ facts, store_unreadable = facts_by_plugin(registry, reasons)
148
+ parts = fact_parts(facts)
149
+ registry.plugins.each { |plugin| collect_plugin_parts(plugin, facts, parts, reasons) }
150
+ # Sort the parts so the digest is independent of plugin registration / iteration order. An unreadable
151
+ # store names no plugin: the snapshot is opaque as a whole, and per-plugin reasons would blame plugins
152
+ # whose facts were simply not read.
107
153
  Result.new(
108
154
  digest: Digest::SHA256.hexdigest(parts.sort.join("\x00")),
109
- opaque_plugin_ids: opaque.uniq.freeze
155
+ opaque_reasons: (store_unreadable ? {} : reasons.sort.to_h).freeze,
156
+ fact_store_unreadable: store_unreadable
110
157
  )
111
158
  end
112
159
 
@@ -119,23 +166,25 @@ module Rigor
119
166
 
120
167
  # Appends one plugin's producer / hook parts to `parts` and marks it opaque when it contributes a type
121
168
  # (`dynamic_return` / `narrowing_facts`) yet declares NONE of (a)/(b)/(c).
122
- def collect_plugin_parts(plugin, facts_by_plugin, parts, opaque)
169
+ def collect_plugin_parts(plugin, facts_by_plugin, parts, reasons)
123
170
  id = safe_id(plugin)
124
171
  has_facts = facts_by_plugin.key?(id)
125
- has_producers = collect_producer_parts(plugin, id, parts, opaque)
126
- has_hook = collect_hook_part(plugin, id, parts, opaque)
172
+ has_producers = collect_producer_parts(plugin, id, parts, reasons)
173
+ has_hook = collect_hook_part(plugin, id, parts, reasons)
127
174
  surface = has_facts || has_producers || has_hook
128
- opaque << id if !surface && contributes_types?(plugin)
175
+ return if surface || !contributes_types?(plugin)
176
+
177
+ mark_opaque(reasons, id, OpaqueReason.new(kind: :no_surface, input: nil, class_name: nil))
129
178
  end
130
179
 
131
- # `{ plugin_id => ["name=digest", ...] }` for every ADR-9 published fact (channel a). A plugin that
132
- # published nothing does not appear.
180
+ # `[{ plugin_id => ["name=digest", ...] }, unreadable]` for every ADR-9 published fact (channel a). A plugin
181
+ # that published nothing does not appear.
133
182
  #
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)
183
+ # A fact the fingerprint cannot digest is an input it cannot see, so its publisher is opaque, whether or not
184
+ # the publisher contributes a type itself: other plugins read its facts. A fact store that cannot be read at
185
+ # all makes the whole surface opaque. Both force the full run; dropping the facts instead would let a plugin
186
+ # with another surface reuse a snapshot its facts no longer match.
187
+ def facts_by_plugin(registry, reasons)
139
188
  result = Hash.new { |hash, key| hash[key] = [] }
140
189
  fact_store = registry.plugins.first&.services&.fact_store
141
190
  fact_store&.each_fact do |fact|
@@ -143,13 +192,12 @@ module Rigor
143
192
  begin
144
193
  result[plugin_id] << "#{fact.name}=#{digest_value(fact.value)}"
145
194
  rescue StandardError
146
- opaque << plugin_id
195
+ mark_opaque(reasons, plugin_id, undigestible("fact #{fact.name}", fact.value))
147
196
  end
148
197
  end
149
- result
198
+ [result, false]
150
199
  rescue StandardError
151
- registry.plugins.each { |plugin| opaque << safe_id(plugin) }
152
- Hash.new { |hash, key| hash[key] = [] }
200
+ [Hash.new { |hash, key| hash[key] = [] }, true]
153
201
  end
154
202
 
155
203
  # Channel (b): a stable signature of every declared producer's VALUE — a digest of the producer's own
@@ -161,28 +209,67 @@ module Rigor
161
209
  # a value-preserving edit keeps the recheck incremental. Returns true when the plugin declared at least
162
210
  # one producer (a surface); a producer whose value cannot be digested marks the plugin opaque.
163
211
  # rubocop:disable-next Naming/PredicateMethod -- reports has-surface AND appends parts (a side-effecting builder)
164
- def collect_producer_parts(plugin, id, parts, opaque)
212
+ def collect_producer_parts(plugin, id, parts, reasons)
165
213
  producer_ids = plugin.class.producers.keys
166
214
  return false if producer_ids.empty?
167
215
 
168
216
  producer_ids.sort.each do |producer_id|
169
- parts << "prod\x1f#{id}\x1f#{producer_id}\x1f#{digest_value(plugin.producer_value(producer_id))}"
170
- rescue StandardError
171
- opaque << id
217
+ input = "producer #{producer_id}"
218
+ begin
219
+ value = plugin.producer_value(producer_id)
220
+ rescue StandardError => e
221
+ mark_opaque(reasons, id, raised(input, e))
222
+ next
223
+ end
224
+ digest_part(parts, reasons, id, input, "prod\x1f#{id}\x1f#{producer_id}", value)
172
225
  end
173
226
  true
174
227
  end
175
228
 
176
- # Channel (c): the optional {Plugin::Base#incremental_state_fingerprint} hook. Returns true when the
177
- # plugin defines it (it has a surface).
178
- def collect_hook_part(plugin, id, parts, opaque)
229
+ # Channel (c): the optional {Plugin::Base#incremental_state_fingerprint} hook. Returns true when the plugin
230
+ # defines it (it has a surface).
231
+ # rubocop:disable-next Naming/PredicateMethod -- reports has-surface AND appends parts (a side-effecting builder)
232
+ def collect_hook_part(plugin, id, parts, reasons)
179
233
  return false unless plugin.respond_to?(:incremental_state_fingerprint)
180
234
 
181
- parts << "hook\x1f#{id}\x1f#{digest_value(plugin.incremental_state_fingerprint)}"
235
+ input = "hook incremental_state_fingerprint"
236
+ begin
237
+ value = plugin.incremental_state_fingerprint
238
+ rescue StandardError => e
239
+ mark_opaque(reasons, id, raised(input, e))
240
+ return true
241
+ end
242
+ digest_part(parts, reasons, id, input, "hook\x1f#{id}", value)
182
243
  true
244
+ end
245
+
246
+ # Appends `prefix\x1f<digest of value>` to `parts`, or marks the plugin opaque when the value cannot be digested.
247
+ def digest_part(parts, reasons, id, input, prefix, value)
248
+ parts << "#{prefix}\x1f#{digest_value(value)}"
183
249
  rescue StandardError
184
- opaque << id
185
- true
250
+ mark_opaque(reasons, id, undigestible(input, value))
251
+ end
252
+
253
+ # The first reason a plugin is found opaque wins: one reason per plugin id, as {Result#opaque_reasons} states.
254
+ def mark_opaque(reasons, id, reason)
255
+ reasons[id] ||= reason
256
+ end
257
+
258
+ def undigestible(input, value)
259
+ OpaqueReason.new(kind: :undigestible, input: input, class_name: class_name_of(value))
260
+ end
261
+
262
+ def raised(input, error)
263
+ OpaqueReason.new(kind: :raised, input: input, class_name: class_name_of(error))
264
+ end
265
+
266
+ # The name of `object`'s class, "anonymous Class" for an anonymous one, or nil when even the class is unreadable
267
+ # (a BasicObject has no `#class`).
268
+ def class_name_of(object)
269
+ klass = object.class
270
+ klass.name || "anonymous Class"
271
+ rescue StandardError
272
+ nil
186
273
  end
187
274
 
188
275
  # A digest of a fact / producer / hook value that depends on the value alone (issue #1574). A producer's