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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/docs/handbook/11-sig-gen.md +12 -5
- data/docs/manual/02-cli-reference.md +19 -1
- data/docs/manual/03-configuration.md +6 -4
- data/docs/manual/05-inspecting-types.md +50 -0
- data/docs/manual/12-caching.md +18 -1
- data/docs/manual/14-rails-quickstart.md +2 -2
- data/docs/manual/18-removing-dead-code.md +16 -9
- data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +23 -22
- data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
- data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
- data/lib/rigor/analysis/incremental_session.rb +89 -22
- data/lib/rigor/analysis/path_expansion.rb +62 -6
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
- data/lib/rigor/analysis/reachability/graph.rb +119 -15
- data/lib/rigor/analysis/reachability/scan.rb +332 -173
- data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
- data/lib/rigor/analysis/run_cache_probe.rb +1 -1
- data/lib/rigor/analysis/run_stats.rb +28 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
- data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
- data/lib/rigor/analysis/runner.rb +29 -11
- data/lib/rigor/analysis/worker_session.rb +1 -1
- data/lib/rigor/cache/engine_source.rb +21 -7
- data/lib/rigor/cache/incremental_snapshot.rb +36 -8
- data/lib/rigor/cli/check_command.rb +138 -39
- data/lib/rigor/cli/docs_command.rb +5 -4
- data/lib/rigor/cli/lens_command.rb +144 -0
- data/lib/rigor/cli/lsp_command.rb +5 -2
- data/lib/rigor/cli/skill_command.rb +3 -3
- data/lib/rigor/cli/skill_describe.rb +1 -1
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/unused_command.rb +157 -24
- data/lib/rigor/cli.rb +30 -4
- data/lib/rigor/configuration.rb +15 -0
- data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
- data/lib/rigor/environment/installed_gem_set.rb +38 -11
- data/lib/rigor/environment/lockfile_resolver.rb +37 -6
- data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
- data/lib/rigor/environment/rbs_loader.rb +33 -0
- data/lib/rigor/environment/required_features.rb +4 -2
- data/lib/rigor/environment.rb +12 -5
- data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
- data/lib/rigor/inference/captured_locals.rb +43 -3
- data/lib/rigor/inference/expression_typer.rb +58 -25
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
- data/lib/rigor/inference/match_rebinding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
- data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
- data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +52 -3
- data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
- data/lib/rigor/inference/project_patched_methods.rb +2 -2
- data/lib/rigor/inference/project_patched_scanner.rb +38 -24
- data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
- data/lib/rigor/inference/refined_dispatch.rb +62 -14
- data/lib/rigor/inference/repeated_or_writes.rb +2 -0
- data/lib/rigor/inference/scope_indexer.rb +140 -5
- data/lib/rigor/inference/statement_evaluator.rb +223 -31
- data/lib/rigor/inference/untyped_rejoin.rb +75 -0
- data/lib/rigor/language_server/project_context.rb +56 -5
- data/lib/rigor/lens/anchor.rb +115 -0
- data/lib/rigor/lens/contract_reader.rb +118 -0
- data/lib/rigor/lens/document.rb +113 -0
- data/lib/rigor/lens/file_reader.rb +433 -0
- data/lib/rigor/lens/members.rb +153 -0
- data/lib/rigor/lens/parameters.rb +91 -0
- data/lib/rigor/lens/plugins.rb +40 -0
- data/lib/rigor/lens/run.rb +280 -0
- data/lib/rigor/lens/slot.rb +79 -0
- data/lib/rigor/lens.rb +40 -0
- data/lib/rigor/project_environment.rb +20 -1
- data/lib/rigor/protection/mutation_cache.rb +2 -1
- data/lib/rigor/scope.rb +22 -11
- data/lib/rigor/sig_gen/generator.rb +45 -5
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +6 -3
- data/skills/rigor-project-init/references/02-configure.md +2 -2
- 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
|
-
@
|
|
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
|
|
210
|
-
reused = (current & previous) -
|
|
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
|
|
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. `
|
|
543
|
-
#
|
|
544
|
-
attr_reader :
|
|
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
|
-
@
|
|
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
|
|
600
|
-
# published facts — there it falls back to the always-sequential probe. Both
|
|
601
|
-
# digest for a given fact surface, so the reuse decision is worker-count-independent
|
|
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
|
-
|
|
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? &&
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
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
|
-
!
|
|
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
|
-
|
|
143
|
+
empty = registry.nil? || registry.empty?
|
|
144
|
+
return Result.new(digest: nil, opaque_reasons: {}.freeze, fact_store_unreadable: false) if empty
|
|
101
145
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
parts = fact_parts(
|
|
105
|
-
registry.plugins.each { |plugin| collect_plugin_parts(plugin,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
126
|
-
has_hook = collect_hook_part(plugin, id, parts,
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
def facts_by_plugin(registry,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
#
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|