rigortype 0.4.5 → 0.4.6
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 +11 -2
- data/docs/manual/02-cli-reference.md +46 -7
- data/docs/manual/03-configuration.md +2 -1
- data/docs/manual/04-diagnostics.md +1 -1
- data/docs/manual/10-mcp-server.md +9 -1
- data/docs/manual/12-caching.md +8 -1
- data/docs/manual/plugins/rigor-actionmailer.md +30 -0
- data/docs/manual/plugins/rigor-actionpack.md +30 -0
- data/docs/manual/plugins/rigor-devise.md +15 -0
- data/docs/manual/plugins/rigor-rspec.md +2 -1
- data/lib/rigor/analysis/buffer_binding.rb +14 -0
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
- data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
- data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
- data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
- data/lib/rigor/analysis/check_rules.rb +93 -8
- data/lib/rigor/analysis/dependency_recorder.rb +11 -0
- data/lib/rigor/analysis/edit_replay.rb +67 -0
- data/lib/rigor/analysis/incremental.rb +37 -0
- data/lib/rigor/analysis/incremental_session.rb +175 -27
- data/lib/rigor/analysis/path_expansion.rb +41 -0
- data/lib/rigor/analysis/require_relative_closure.rb +100 -0
- data/lib/rigor/analysis/rule_catalog.rb +5 -1
- data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
- data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
- data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
- data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
- data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
- data/lib/rigor/analysis/runner.rb +156 -49
- data/lib/rigor/analysis/worker_session.rb +14 -2
- data/lib/rigor/cache/incremental_snapshot.rb +30 -7
- data/lib/rigor/cli/check_command.rb +93 -4
- data/lib/rigor/cli/diff_command.rb +2 -1
- data/lib/rigor/cli/lens_command.rb +5 -5
- data/lib/rigor/cli/mcp_command.rb +2 -1
- data/lib/rigor/cli/options.rb +20 -3
- data/lib/rigor/cli/plugins_command.rb +5 -0
- data/lib/rigor/cli/plugins_renderer.rb +24 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +46 -7
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/type_of_project_scope.rb +27 -0
- data/lib/rigor/config_audit.rb +16 -5
- data/lib/rigor/configuration.rb +14 -4
- data/lib/rigor/environment/rbs_loader.rb +115 -6
- data/lib/rigor/environment/required_features.rb +136 -29
- data/lib/rigor/environment/ruby_library_roots.rb +122 -0
- data/lib/rigor/environment.rb +4 -2
- data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
- data/lib/rigor/inference/deep_erasure.rb +138 -0
- data/lib/rigor/inference/def_return_typer.rb +159 -9
- data/lib/rigor/inference/definer_resolution.rb +17 -3
- data/lib/rigor/inference/error_info.rb +17 -3
- data/lib/rigor/inference/expression_typer.rb +62 -13
- data/lib/rigor/inference/global_write_census.rb +36 -1
- data/lib/rigor/inference/ivar_writer_census.rb +195 -0
- data/lib/rigor/inference/loop_value_record.rb +53 -0
- data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
- data/lib/rigor/inference/method_dispatcher.rb +46 -16
- data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
- data/lib/rigor/inference/module_function_state.rb +12 -0
- data/lib/rigor/inference/mutation_widening.rb +5 -2
- data/lib/rigor/inference/narrowing.rb +66 -5
- data/lib/rigor/inference/object_mixins.rb +13 -1
- data/lib/rigor/inference/project_seed_tables.rb +2 -1
- data/lib/rigor/inference/project_yielder.rb +128 -0
- data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
- data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
- data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
- data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
- data/lib/rigor/inference/scope_indexer.rb +480 -103
- data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
- data/lib/rigor/inference/statement_evaluator.rb +658 -90
- data/lib/rigor/inference/synthetic_method_index.rb +23 -0
- data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
- data/lib/rigor/language_server/buffer_table.rb +6 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
- data/lib/rigor/language_server/project_context.rb +59 -3
- data/lib/rigor/language_server/server.rb +5 -3
- data/lib/rigor/mcp/server.rb +9 -1
- data/lib/rigor/plugin/base.rb +26 -11
- data/lib/rigor/plugin/manifest.rb +29 -3
- data/lib/rigor/plugin/registry.rb +38 -0
- data/lib/rigor/protection/discovery_seed.rb +57 -2
- data/lib/rigor/rooted_glob.rb +10 -1
- data/lib/rigor/scope.rb +14 -5
- data/lib/rigor/sig_gen/generator.rb +308 -37
- data/lib/rigor/sig_gen/observation_collector.rb +4 -1
- data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
- data/lib/rigor/sig_gen/renderer.rb +6 -3
- data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
- data/lib/rigor/type/combinator.rb +4 -3
- data/lib/rigor/type/constant.rb +45 -3
- data/lib/rigor/type/hash_shape.rb +14 -9
- data/lib/rigor/type/struct_instance.rb +3 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +3 -2
- data/sig/rigor/effects/file_collection.rbs +5 -6
- data/sig/rigor/effects/registry.rbs +8 -6
- data/sig/rigor/effects/summary.rbs +6 -5
- data/sig/rigor/environment.rbs +5 -3
- data/sig/rigor/inference/optimistic_origin.rbs +11 -0
- data/sig/rigor/inference.rbs +6 -0
- data/sig/rigor/plugin/additional_initializer.rbs +5 -5
- data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
- data/sig/rigor/plugin/effect_attribution.rbs +25 -10
- data/sig/rigor/plugin/effect_edge.rbs +12 -5
- data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
- data/sig/rigor/plugin/io_boundary.rbs +4 -4
- data/sig/rigor/plugin/manifest.rbs +16 -18
- data/sig/rigor/plugin/protocol_contract.rbs +10 -9
- data/sig/rigor/plugin/registry.rbs +1 -1
- data/sig/rigor/scope.rbs +31 -50
- data/sig/rigor/type.rbs +0 -6
- metadata +20 -1
|
@@ -5,6 +5,7 @@ require_relative "incremental"
|
|
|
5
5
|
require_relative "incremental_run_slot"
|
|
6
6
|
require_relative "incremental_run_slot/write_guard"
|
|
7
7
|
require_relative "plugin_fact_fingerprint"
|
|
8
|
+
require_relative "run_disclosure_ledger"
|
|
8
9
|
require_relative "source_rbs_gate"
|
|
9
10
|
require_relative "../cache/file_digest"
|
|
10
11
|
require_relative "../cache/incremental_snapshot"
|
|
@@ -139,6 +140,9 @@ module Rigor
|
|
|
139
140
|
# the frozen `Cache::Descriptor::FileEntry` / `GlobEntry` rows, as each read observed its input. A recheck
|
|
140
141
|
# re-analyses every file one of whose rows no longer validates ({#plugin_read_affected}).
|
|
141
142
|
@plugin_reads = {}
|
|
143
|
+
# Issue #2020 — the run-scoped plugin rows each analysed file registered, and the reads that make one stale.
|
|
144
|
+
@plugin_disclosures = RunDisclosureLedger.new
|
|
145
|
+
@stale_disclosures = Set.new
|
|
142
146
|
reset_carried_state
|
|
143
147
|
# ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
|
|
144
148
|
# `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
|
|
@@ -269,8 +273,10 @@ module Rigor
|
|
|
269
273
|
@effect_collections = runner.effect_collections_by_path
|
|
270
274
|
@effects_identity = current_effects_identity
|
|
271
275
|
@effect_plugin_digest = effect_plugin_digest_of(runner)
|
|
276
|
+
@synthetic_surface_digest = synthetic_surface_digest_of(runner)
|
|
272
277
|
@cache = per_file(runner.per_file_diagnostics)
|
|
273
278
|
@plugin_reads = plugin_reads_of(runner, @analyzed)
|
|
279
|
+
@plugin_disclosures = RunDisclosureLedger.from(runner.analysis_disclosures.slice(*@analyzed))
|
|
274
280
|
record_baseline_digests
|
|
275
281
|
@run_level_rows = runner.run_level_rows
|
|
276
282
|
diagnostics
|
|
@@ -294,16 +300,11 @@ module Rigor
|
|
|
294
300
|
fresh_params = fresh_param_table(current, changed, added, removed)
|
|
295
301
|
param_files, param_pairs = param_seed_invalidation(fresh_params)
|
|
296
302
|
affected = affected_closure(changed, added, removed, param_files, param_pairs)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
|
|
303
|
-
param_inferred_types: fresh_params, **forced_target_options(forced),
|
|
304
|
-
restored_run_level_rows: @run_level_rows, **served_state(reused))
|
|
305
|
-
merged = run_runner(runner).diagnostics
|
|
306
|
-
return rebaseline_recheck(changed, added, removed) if served_effects_stale?(runner, reused)
|
|
303
|
+
affected, analyze_set, runner, merged, reused =
|
|
304
|
+
run_settled_closure(affected, [current, previous, removed], fresh_params)
|
|
305
|
+
if served_effects_stale?(runner, reused) || synthetic_surface_moved?(runner, reused)
|
|
306
|
+
return rebaseline_recheck(changed, added, removed)
|
|
307
|
+
end
|
|
307
308
|
|
|
308
309
|
absorb(runner, current, analyze_set, removed)
|
|
309
310
|
@param_table = fresh_params
|
|
@@ -311,6 +312,36 @@ module Rigor
|
|
|
311
312
|
stale_readers: @stale_plugin_readers)
|
|
312
313
|
end
|
|
313
314
|
|
|
315
|
+
# {#recheck}'s narrowed run. Issue #2020 — a stale run-scoped key whose chosen refresher registered nothing (a
|
|
316
|
+
# parse error, a plugin that raised, an edit that took the notice out of its text) is decided by its other
|
|
317
|
+
# holders, so they join the closure and it runs again. Returns `[affected, analyze_set, runner, merged, reused]`.
|
|
318
|
+
def run_settled_closure(affected, (current, previous, removed), fresh_params)
|
|
319
|
+
forced, analyze_set = forced_analyze_set(affected, current)
|
|
320
|
+
runner, merged, reused = run_closure(analyze_set, current, previous, forced, fresh_params)
|
|
321
|
+
fallback = @plugin_disclosures.unresolved_holders(runner.analysis_disclosures, analyze_set, @stale_disclosures,
|
|
322
|
+
removed.to_set)
|
|
323
|
+
return [affected, analyze_set, runner, merged, reused] if fallback.empty?
|
|
324
|
+
|
|
325
|
+
affected = (affected | fallback).freeze
|
|
326
|
+
@stale_plugin_readers |= fallback
|
|
327
|
+
forced, analyze_set = forced_analyze_set(affected, current)
|
|
328
|
+
[affected, analyze_set, *run_closure(analyze_set, current, previous, forced, fresh_params)]
|
|
329
|
+
end
|
|
330
|
+
private :run_settled_closure
|
|
331
|
+
|
|
332
|
+
# One narrowed run of {#recheck}'s closure: the runner, its merged rows, and the files it served. The freshly
|
|
333
|
+
# collected table is handed to the runner so the run seeds from the SAME table the diff was decided on (and the
|
|
334
|
+
# collector runs once per recheck, not twice). The reused files' cached rows go in too, so the runner lists them
|
|
335
|
+
# in the full run's file order (#1524).
|
|
336
|
+
def run_closure(analyze_set, current, previous, forced, fresh_params)
|
|
337
|
+
reused = (current & previous) - analyze_set.to_a
|
|
338
|
+
runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
|
|
339
|
+
param_inferred_types: fresh_params, **forced_target_options(forced),
|
|
340
|
+
restored_run_level_rows: @run_level_rows, **served_state(reused))
|
|
341
|
+
[runner, run_runner(runner).diagnostics, reused]
|
|
342
|
+
end
|
|
343
|
+
private :run_closure
|
|
344
|
+
|
|
314
345
|
# Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
|
|
315
346
|
# file, for the CLI's `--incremental --tmp-file=X --instead-of=Y`. Returns the {Recheck} when the
|
|
316
347
|
# snapshot could be reused, or nil when it could not — the caller then falls back to option A
|
|
@@ -361,7 +392,7 @@ module Rigor
|
|
|
361
392
|
# records none at all), so no dependent set can bound who read it. An edit that moved it re-analyses the
|
|
362
393
|
# whole project — what a `sig/` edit already costs through the snapshot fingerprint.
|
|
363
394
|
def affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new)
|
|
364
|
-
@stale_plugin_readers = plugin_read_affected
|
|
395
|
+
@stale_plugin_readers = plugin_read_affected | disclosure_refreshers(removed)
|
|
365
396
|
return whole_project_closure(changed, added) if source_rbs_gate.moved?(@seed_bundles, changed, added, removed)
|
|
366
397
|
|
|
367
398
|
(gated_closure(changed, added, removed, param_files, param_pairs) | @stale_plugin_readers).freeze
|
|
@@ -471,8 +502,8 @@ module Rigor
|
|
|
471
502
|
# exempts such a write is a fact of the whole program — an alias, or a `write` / `to_str` / `to_int` / hatch
|
|
472
503
|
# defined in any spelling on any receiver, a rewritten ancestor, a mixin — which no name-keyed edge can
|
|
473
504
|
# follow, so a stale report would outlive the edit that exempts it. Such files are rare, so this costs little.
|
|
474
|
-
# A file the census silenced is
|
|
475
|
-
#
|
|
505
|
+
# A file the census silenced is re-checked through the `global_census:` keys its rule recorded
|
|
506
|
+
# ({#global_census_affected}, issue #2158).
|
|
476
507
|
def global_write_reporters
|
|
477
508
|
@cache.each_with_object(Set.new) do |(path, diagnostics), reporters|
|
|
478
509
|
reporters << path if diagnostics.any? { |diagnostic| diagnostic.rule.to_s.start_with?("global.") }
|
|
@@ -620,6 +651,32 @@ module Rigor
|
|
|
620
651
|
run_runner(runner).diagnostics
|
|
621
652
|
end
|
|
622
653
|
|
|
654
|
+
# Issue #1967 — the edit replay of `--verify-incremental-edits`: a warm recheck of this session's baseline with
|
|
655
|
+
# `buffer` standing in for one file, the way editor mode option B (#146) rechecks an unsaved edit. The baseline is
|
|
656
|
+
# handed over as a Marshal copy of the snapshot a `--incremental` run would save, through a fresh session that
|
|
657
|
+
# never persists, so this session's own state is untouched and each replay starts from the same baseline. Unlike
|
|
658
|
+
# {#reanalyze_subset}, whose tree is the baseline's, the edit moves what the served files' rows were computed
|
|
659
|
+
# from, so a dependent the recheck fails to re-analyse is served stale and the comparison sees it. Returns the
|
|
660
|
+
# {Recheck}, or nil when the recheck declined as option B does (the plugin fact surface moved, an unverified
|
|
661
|
+
# source-RBS gate, a re-baseline), where the answer would be a full analysis and there is nothing to compare.
|
|
662
|
+
def replay_edit(buffer)
|
|
663
|
+
adopt_plugin_fact_fingerprint if @plugin_fact_reusable.nil?
|
|
664
|
+
replay = IncrementalSession.new(
|
|
665
|
+
configuration: @configuration, paths: @paths, environment: @environment, cache_store: @cache_store,
|
|
666
|
+
plugin_requirer: @plugin_requirer, buffer: buffer, explain: @explain
|
|
667
|
+
)
|
|
668
|
+
replay.run_buffer_recheck(snapshot: ReplaySnapshot.new(blob: Marshal.dump(to_payload)), fingerprint: :replay)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# The in-memory stand-in for {Cache::IncrementalSnapshot} {#replay_edit} hands its replay session: every load is a
|
|
672
|
+
# fresh copy, as a load from disk is.
|
|
673
|
+
ReplaySnapshot = Data.define(:blob) do
|
|
674
|
+
def load(fingerprint:) # rubocop:disable Lint/UnusedMethodArgument
|
|
675
|
+
Marshal.load(blob) # rubocop:disable Security/MarshalLoad
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
private_constant :ReplaySnapshot
|
|
679
|
+
|
|
623
680
|
# Cross-process incremental run (the `--incremental` flag's engine). With a disk `snapshot` whose
|
|
624
681
|
# `fingerprint` matches, restore the prior per-file state and `#recheck` (re-analyze only the changed
|
|
625
682
|
# closure, serve the rest from the restored cache); otherwise run a full `#baseline`. Either way,
|
|
@@ -991,7 +1048,7 @@ module Rigor
|
|
|
991
1048
|
restore_structural_declarations(payload)
|
|
992
1049
|
@return_summaries = payload.return_summaries || {}
|
|
993
1050
|
@param_table = payload.param_table || {} # ADR-67 WD6c lift — the seeds the cache was built under.
|
|
994
|
-
|
|
1051
|
+
restore_plugin_records(payload)
|
|
995
1052
|
# Issues #796 / #794 — absent (nil) in a snapshot written before schema 22 is impossible (the SCHEMA
|
|
996
1053
|
# gate rejects one), so nil here means the writing run genuinely had nothing to say.
|
|
997
1054
|
@run_level_rows = payload.run_level_rows
|
|
@@ -1007,6 +1064,14 @@ module Rigor
|
|
|
1007
1064
|
@negative_dependents = Incremental.invert(@missing)
|
|
1008
1065
|
end
|
|
1009
1066
|
|
|
1067
|
+
# Issue #1553 — the plugin reads each file's last analysis made, and (#2020) the run-scoped plugin rows it
|
|
1068
|
+
# registered; `{}` in a payload built without them. Extracted to keep {#restore} under its ABC budget.
|
|
1069
|
+
def restore_plugin_records(payload)
|
|
1070
|
+
@plugin_reads = payload.plugin_reads
|
|
1071
|
+
@plugin_disclosures = RunDisclosureLedger.load(payload.plugin_disclosures)
|
|
1072
|
+
@synthetic_surface_digest = payload.synthetic_surface_digest # the plugin-built ADR-16 index (PR #2286 M1)
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1010
1075
|
# ADR-46 slice 3 / issue #644 — the structural tier's negative-edge state: the per-consumer missed
|
|
1011
1076
|
# lookups plus the two per-file DECLARATION sets (`class_decls`, `constant_decls`) whose diff says what
|
|
1012
1077
|
# APPEARED in an edit. Absent in an older snapshot → empty, which only loses the appeared-symbol
|
|
@@ -1032,7 +1097,9 @@ module Rigor
|
|
|
1032
1097
|
effects_identity: @effects_identity,
|
|
1033
1098
|
run_level_rows: @run_level_rows,
|
|
1034
1099
|
effect_plugin_digest: @effect_plugin_digest,
|
|
1100
|
+
synthetic_surface_digest: @synthetic_surface_digest,
|
|
1035
1101
|
plugin_reads: @plugin_reads,
|
|
1102
|
+
plugin_disclosures: @plugin_disclosures.to_h,
|
|
1036
1103
|
discovery_digests: @discovery_digests
|
|
1037
1104
|
)
|
|
1038
1105
|
end
|
|
@@ -1152,11 +1219,13 @@ module Rigor
|
|
|
1152
1219
|
@digests[path] = pack_digest(path)
|
|
1153
1220
|
fresh_reads.key?(path) ? @plugin_reads[path] = fresh_reads[path] : @plugin_reads.delete(path)
|
|
1154
1221
|
end
|
|
1222
|
+
absorb_disclosures(runner, analyze_set)
|
|
1155
1223
|
forget_unbound_digests
|
|
1156
1224
|
absorb_dependency_graph(runner)
|
|
1157
1225
|
# Issues #796 / #794 — the recheck's own snapshots already carry the replayed rows folded together
|
|
1158
1226
|
# with anything its closure demanded for itself, so this is the same union the next run replays.
|
|
1159
1227
|
@run_level_rows = runner.run_level_rows
|
|
1228
|
+
@synthetic_surface_digest = synthetic_surface_digest_of(runner)
|
|
1160
1229
|
refresh_return_summaries(runner, analyze_set)
|
|
1161
1230
|
refresh_effect_collections(runner, analyze_set)
|
|
1162
1231
|
end
|
|
@@ -1200,6 +1269,7 @@ module Rigor
|
|
|
1200
1269
|
@digests.delete(path)
|
|
1201
1270
|
@sources.delete(path)
|
|
1202
1271
|
@plugin_reads.delete(path)
|
|
1272
|
+
@plugin_disclosures.forget(path) # Unpinned hygiene: a removed file is never served, so never replayed.
|
|
1203
1273
|
@symbol_sources.delete(path)
|
|
1204
1274
|
@ancestry_sources.delete(path)
|
|
1205
1275
|
@missing.delete(path)
|
|
@@ -1271,7 +1341,8 @@ module Rigor
|
|
|
1271
1341
|
# top-level def, `method:C#m` otherwise), then unions the recorded negative-dependents of those keys.
|
|
1272
1342
|
# Issue #1120 — plus the consumers of any refinement name the edit moved ({#refinement_affected}). Issue #1367 —
|
|
1273
1343
|
# and every file whose last result holds a `global.*` diagnostic ({#global_write_reporters}). Issue #1459 — and
|
|
1274
|
-
# the consumers of any method name a file started or stopped defining ({#defined_name_affected}).
|
|
1344
|
+
# the consumers of any method name a file started or stopped defining ({#defined_name_affected}). Issue #2134 —
|
|
1345
|
+
# and the consumers of any visibility slot a file moved ({#visibility_name_affected}).
|
|
1275
1346
|
def negative_affected(changed, removed, new_fingerprints, new_class_decls, new_constant_decls, summary = nil)
|
|
1276
1347
|
appeared_methods = Incremental.appeared_symbols(changed, @symbol_fingerprints, new_fingerprints)
|
|
1277
1348
|
# Issue #639 — a class DISAPPEARING satisfies the `class:` kind too, now that a resolved bare
|
|
@@ -1288,11 +1359,18 @@ module Rigor
|
|
|
1288
1359
|
keys = appeared_methods.map { |symbol| negative_key_for(symbol) }
|
|
1289
1360
|
keys.concat(class_move_keys(moved_classes))
|
|
1290
1361
|
keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
|
|
1291
|
-
Incremental.negative_closure(keys, @negative_dependents) |
|
|
1292
|
-
defined_name_affected(changed + removed, summary) | mark_affected(changed + removed, summary) |
|
|
1362
|
+
Incremental.negative_closure(keys, @negative_dependents) | table_diff_affected(changed + removed, summary) |
|
|
1293
1363
|
global_write_reporters
|
|
1294
1364
|
end
|
|
1295
1365
|
|
|
1366
|
+
# The consumers of the name-keyed edges whose producers diff a per-file table between each file's seed bundle
|
|
1367
|
+
# and the scan.
|
|
1368
|
+
def table_diff_affected(paths, summary)
|
|
1369
|
+
refinement_affected(paths, summary) | defined_name_affected(paths, summary) |
|
|
1370
|
+
visibility_name_affected(paths, summary) | mark_affected(paths, summary) |
|
|
1371
|
+
global_census_affected(paths, summary) | ivar_set_affected(paths, summary)
|
|
1372
|
+
end
|
|
1373
|
+
|
|
1296
1374
|
# Issue #1833 — the consumers whose answer read a class mark that moved in this edit. A reader of a class's
|
|
1297
1375
|
# envelope bucket records `mark:<last segment>` (`Scope#parameter_envelopes_of`), whichever way it answered, so
|
|
1298
1376
|
# a file that adds or drops only a mark (`x.singleton_class.prepend(Comparable)`, `o.extend(Helper)` in a
|
|
@@ -1379,6 +1457,53 @@ module Rigor
|
|
|
1379
1457
|
Incremental.negative_closure(names.map { |name| "defines:#{name}" }, @negative_dependents)
|
|
1380
1458
|
end
|
|
1381
1459
|
|
|
1460
|
+
# Issue #2158 — the consumers whose `global.*` write rule read a census entry that a file in `paths` started or
|
|
1461
|
+
# stopped writing. The before-state is each file's seed bundle, the after-state the scan's per-file census. The
|
|
1462
|
+
# write the census silenced reports nothing, so {#global_write_reporters} does not reach it.
|
|
1463
|
+
def global_census_affected(paths, summary)
|
|
1464
|
+
after = (summary && summary[:global_write_census]) || {}
|
|
1465
|
+
before = paths.to_h { |path| [path, @seed_bundles.dig(path, :global_write_census)] }
|
|
1466
|
+
keys = Incremental.changed_global_census_keys(paths, before, after)
|
|
1467
|
+
return Set.new if keys.empty?
|
|
1468
|
+
|
|
1469
|
+
Incremental.negative_closure(keys, @negative_dependents)
|
|
1470
|
+
end
|
|
1471
|
+
|
|
1472
|
+
# Issue #1154 — the consumers whose declared-parameter ivar credit read an ivar writer entry a file in `paths`
|
|
1473
|
+
# started or stopped recording ({GlobalWriteCensus.ivar_key}: `ivar_set:@x`, `ivar_set_class:K`,
|
|
1474
|
+
# `own_write:K#@x`). The before-state is each file's seed bundle, the after-state the scan's per-file census. The
|
|
1475
|
+
# write sits in a method body, so neither the declaration signature nor the symbol fingerprints move with it.
|
|
1476
|
+
def ivar_set_affected(paths, summary)
|
|
1477
|
+
after = (summary && summary[:global_write_census]) || {}
|
|
1478
|
+
keys = Set.new
|
|
1479
|
+
paths.each do |path|
|
|
1480
|
+
census = @seed_bundles.dig(path, :global_write_census) || Inference::GlobalWriteCensus::EMPTY
|
|
1481
|
+
before = Inference::GlobalWriteCensus.ivar_set_entries(census)
|
|
1482
|
+
now = Inference::GlobalWriteCensus.ivar_set_entries(after[path] || Inference::GlobalWriteCensus::EMPTY)
|
|
1483
|
+
(before ^ now).each { |entry| keys << Inference::GlobalWriteCensus.ivar_key(entry) }
|
|
1484
|
+
end
|
|
1485
|
+
return Set.new if keys.empty?
|
|
1486
|
+
|
|
1487
|
+
Incremental.negative_closure(keys.to_a, @negative_dependents)
|
|
1488
|
+
end
|
|
1489
|
+
|
|
1490
|
+
# Issue #2134 — the consumers whose answer read a class's visibility slot for a method, or its contest, for each
|
|
1491
|
+
# slot whose entries a file in `paths` added, dropped or changed (`Scope#discovered_method_visibility`). The
|
|
1492
|
+
# before-state is each file's seed bundle, the after-state the scan's per-file entries. A `private :foo` in a
|
|
1493
|
+
# reopening or a `class_eval` block moves no `def`, so neither the symbol fingerprints nor the defined-name diff
|
|
1494
|
+
# sees it.
|
|
1495
|
+
def visibility_name_affected(paths, summary)
|
|
1496
|
+
after = (summary && summary[:visibility_entries]) || {}
|
|
1497
|
+
before = paths.to_h do |path|
|
|
1498
|
+
bundle = @seed_bundles[path]
|
|
1499
|
+
[path, bundle && Inference::ScopeIndexer.visibility_entries(bundle)]
|
|
1500
|
+
end
|
|
1501
|
+
slots = Incremental.changed_visibility_slots(paths, before, after)
|
|
1502
|
+
return Set.new if slots.empty?
|
|
1503
|
+
|
|
1504
|
+
Incremental.negative_closure(slots.map { |slot| "visibility:#{slot}" }, @negative_dependents)
|
|
1505
|
+
end
|
|
1506
|
+
|
|
1382
1507
|
# The qualified class/module names declared in the pre-parsed `index` (shared with
|
|
1383
1508
|
# {#symbol_fingerprints_from_index}, so the changed set is parsed once). `{ path => Set<class name> }`.
|
|
1384
1509
|
def class_declarations_from_index(index)
|
|
@@ -1541,7 +1666,22 @@ module Rigor
|
|
|
1541
1666
|
# and (#1898) their effect collections, so the run closes its effect graph over the whole project.
|
|
1542
1667
|
def served_state(reused)
|
|
1543
1668
|
{ served_per_file_diagnostics: @cache.slice(*reused),
|
|
1544
|
-
served_effect_collections: @effect_collections.slice(*reused)
|
|
1669
|
+
served_effect_collections: @effect_collections.slice(*reused),
|
|
1670
|
+
served_run_disclosures: @plugin_disclosures.served(reused, @stale_disclosures) }
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
# Issue #2020 — folds the run's run-scoped registrations into the ledger, refreshing the keys this recheck found
|
|
1674
|
+
# stale. Extracted to keep {#absorb} under its ABC budget.
|
|
1675
|
+
def absorb_disclosures(runner, analyze_set)
|
|
1676
|
+
@plugin_disclosures.absorb(runner.analysis_disclosures, analyze_set, @stale_disclosures)
|
|
1677
|
+
@stale_disclosures = Set.new
|
|
1678
|
+
end
|
|
1679
|
+
|
|
1680
|
+
# Issue #2020 — the run-scoped plugin rows whose reads went stale ({RunDisclosureLedger}): one registering file
|
|
1681
|
+
# each joins the closure, and their replays are withheld until {#absorb} refreshes them from that file.
|
|
1682
|
+
def disclosure_refreshers(removed)
|
|
1683
|
+
@stale_disclosures = @plugin_disclosures.stale_identities { |row| plugin_read_fresh?(row) }
|
|
1684
|
+
@plugin_disclosures.refreshers(@stale_disclosures, removed.to_set)
|
|
1545
1685
|
end
|
|
1546
1686
|
|
|
1547
1687
|
# Issue #1898 — the plugin effect-fact digest a run's collections were stamped under. A plugin writes its
|
|
@@ -1560,6 +1700,21 @@ module Rigor
|
|
|
1560
1700
|
effect_plugin_digest_of(runner) != @effect_plugin_digest
|
|
1561
1701
|
end
|
|
1562
1702
|
|
|
1703
|
+
# PR #2286 review M1 — whether the ADR-16 synthetic-method index `runner` built differs from the one the
|
|
1704
|
+
# served files were analysed under. The index comes from class-body DSL calls (`devise :confirmable`,
|
|
1705
|
+
# a Tier C `attribute`), which no declaration signature records and no dependency edge names, so a file
|
|
1706
|
+
# that dispatched through a synthesised member is not in the closure of the edit that removed it. The
|
|
1707
|
+
# comparison waits for the run because the index is built by its pre-pass; a move re-analyses the project.
|
|
1708
|
+
def synthetic_surface_moved?(runner, reused)
|
|
1709
|
+
return false if reused.empty?
|
|
1710
|
+
|
|
1711
|
+
synthetic_surface_digest_of(runner) != @synthetic_surface_digest
|
|
1712
|
+
end
|
|
1713
|
+
|
|
1714
|
+
def synthetic_surface_digest_of(runner)
|
|
1715
|
+
runner.synthetic_method_index&.surface_digest
|
|
1716
|
+
end
|
|
1717
|
+
|
|
1563
1718
|
# The answer {#recheck} gives when {#served_effects_stale?}: a full baseline, reported as a recheck that
|
|
1564
1719
|
# re-analysed every file. {#run_incremental} reads `rebaselined` so it neither runs a second baseline
|
|
1565
1720
|
# nor reports the run as warm.
|
|
@@ -1644,19 +1799,12 @@ module Rigor
|
|
|
1644
1799
|
def rebind_buffer(files)
|
|
1645
1800
|
return if @buffer.nil? || files.include?(@buffer.logical_path)
|
|
1646
1801
|
|
|
1647
|
-
target = real_path(@buffer.logical_path)
|
|
1648
|
-
return if target.nil?
|
|
1802
|
+
target = BufferBinding.real_path(@buffer.logical_path)
|
|
1649
1803
|
|
|
1650
|
-
match = files.find { |path| real_path(path) == target }
|
|
1804
|
+
match = files.find { |path| BufferBinding.real_path(path) == target }
|
|
1651
1805
|
@buffer = BufferBinding.new(logical_path: match, physical_path: @buffer.physical_path) if match
|
|
1652
1806
|
end
|
|
1653
1807
|
|
|
1654
|
-
def real_path(path)
|
|
1655
|
-
File.realpath(path)
|
|
1656
|
-
rescue StandardError
|
|
1657
|
-
nil
|
|
1658
|
-
end
|
|
1659
|
-
|
|
1660
1808
|
# True when `path`'s recorded stat entry proves it unchanged since the last analysis. Any stat / parse
|
|
1661
1809
|
# failure (missing entry, unreadable / vanished file) reads as NOT fresh (→ re-analyse), preserving the
|
|
1662
1810
|
# prior `digest(path) != recorded` "changed" semantics for a file that cannot be validated.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../rooted_glob"
|
|
4
|
+
require_relative "diagnostic"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
module Analysis
|
|
@@ -10,8 +11,13 @@ module Rigor
|
|
|
10
11
|
# is taken verbatim; anything else contributes no files here (the Runner layers its own path-error
|
|
11
12
|
# diagnostics and buffer / in-memory handling on top — none of which add or remove files for an ordinary
|
|
12
13
|
# on-disk check, so the two file sets agree).
|
|
14
|
+
#
|
|
15
|
+
# #1991 — also the one place a path that names neither a directory nor a `.rb` file is worded and
|
|
16
|
+
# severity-graded, so `rigor check` and `rigor sig-gen` report the same path error the same way.
|
|
13
17
|
module PathExpansion
|
|
14
18
|
RUBY_GLOB = "**/*.rb"
|
|
19
|
+
NO_SUCH_PATH = "no such file or directory"
|
|
20
|
+
NOT_A_RUBY_FILE = "not a Ruby file (expected `.rb` or a directory)"
|
|
15
21
|
|
|
16
22
|
module_function
|
|
17
23
|
|
|
@@ -28,10 +34,45 @@ module Rigor
|
|
|
28
34
|
end
|
|
29
35
|
end
|
|
30
36
|
|
|
37
|
+
# The `[path, message]` pairs for each of `paths` that names neither a directory nor a `.rb` file,
|
|
38
|
+
# worded as `rigor check` words them.
|
|
39
|
+
def unusable(paths)
|
|
40
|
+
Array(paths).each_with_object([]) do |path, bad|
|
|
41
|
+
next if File.directory?(path) || (File.file?(path) && path.end_with?(".rb"))
|
|
42
|
+
|
|
43
|
+
bad << [path, unusable_message(path)]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def unusable_message(path)
|
|
48
|
+
File.exist?(path) ? NOT_A_RUBY_FILE : NO_SUCH_PATH
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The error diagnostics for `bad` (from {unusable}). A bad path among valid ones is a warning that it was
|
|
52
|
+
# skipped; a bad path that leaves nothing to analyse is an error, so a lone typo is not masked.
|
|
53
|
+
def diagnostics(bad, any_files:)
|
|
54
|
+
severity = any_files ? :warning : :error
|
|
55
|
+
suffix = any_files ? " (skipped)" : ""
|
|
56
|
+
bad.map do |path, message|
|
|
57
|
+
Diagnostic.new(path: path, line: 1, column: 1, message: "#{message}#{suffix}", severity: severity)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
31
61
|
def directory_files(dir, exclude_patterns, base: Dir.pwd)
|
|
32
62
|
reject_excluded(RootedGlob.glob(dir, RUBY_GLOB), exclude_patterns, base: base)
|
|
33
63
|
end
|
|
34
64
|
|
|
65
|
+
# The configured project files, expanded as `rigor check` expands its configured paths: a directory globs,
|
|
66
|
+
# any other entry (a file, or a path that does not exist) is kept verbatim and left to the reader to skip.
|
|
67
|
+
# The file list a cross-file discovery seed is folded over ({Protection::DiscoverySeed}).
|
|
68
|
+
def configured_files(configuration)
|
|
69
|
+
Array(configuration.paths).flat_map do |path|
|
|
70
|
+
next [path] unless File.directory?(path)
|
|
71
|
+
|
|
72
|
+
directory_files(path, configuration.exclude_patterns, base: configuration.exclude_base)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
35
76
|
# `exclude_patterns` are matched with `File.fnmatch?` WITHOUT `FNM_PATHNAME`, so `**`/`*` span separators
|
|
36
77
|
# (the patterns behave like substring globs) — the exact semantics the Runner's directory glob applies.
|
|
37
78
|
#
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Analysis
|
|
7
|
+
# Issue #2021 — the files a run analyses outside the configured `paths:` load through `require_relative`.
|
|
8
|
+
#
|
|
9
|
+
# A run over path arguments widens its discovery to the configured paths (#684), which is the whole project for
|
|
10
|
+
# a file inside them. A file outside them has no such project: `rigor check plugins/x/lib/rigor/plugin/x.rb`
|
|
11
|
+
# in a repository whose `paths:` is `lib` discovered `x.rb` and `lib` alone. The plugin's nested `Effects`,
|
|
12
|
+
# declared in the sibling `x/effects.rb`, was then invisible, the lexical lookup fell through to the
|
|
13
|
+
# project's `Rigor::Effects`, and every call on it drew a false `call.undefined-method` that checking the
|
|
14
|
+
# plugin's `lib` did not.
|
|
15
|
+
#
|
|
16
|
+
# The file names its siblings itself: Ruby loads what it `require_relative`s before any of its code runs, so
|
|
17
|
+
# those declarations are in scope at runtime. {.closure} follows that graph from the out-of-paths files,
|
|
18
|
+
# through each target that is itself outside the configured paths. A target under a configured path is
|
|
19
|
+
# already discovered and stops the walk there, so the files inside `paths:` are discovered exactly as a run
|
|
20
|
+
# over the configured paths discovers them. Only a string literal counts (`require_relative "a/b"`, with or
|
|
21
|
+
# without the `.rb`); a computed name, `require`, `load` and `autoload` are not followed, so a gem's own
|
|
22
|
+
# files and a Zeitwerk-loaded tree are not reached this way.
|
|
23
|
+
#
|
|
24
|
+
# The walk sees one entry point's load graph, and a file it does not reach may redefine what a walked file
|
|
25
|
+
# defines. The runner therefore discovers the walked files as uncertain: their declarations resolve constants
|
|
26
|
+
# and their methods exist, but no return type, arity or visibility is read from them.
|
|
27
|
+
module RequireRelativeClosure
|
|
28
|
+
# The walk's answer: the existing targets it reached, and the targets it named that are not files (each an
|
|
29
|
+
# input of the answer too, since one appearing would join the discovery).
|
|
30
|
+
Result = Data.define(:found, :missing)
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# @param seeds — the analysed files outside the configured paths, as spelled by the run.
|
|
35
|
+
# @param covered — `->(real_path) { Boolean }`, whether a target lies under a configured path, where discovery
|
|
36
|
+
# holds it already. The walk neither adds nor follows such a target. Paths are compared as real paths, so a
|
|
37
|
+
# symlinked spelling of a configured file is covered too.
|
|
38
|
+
# @param read — `->(path) { String }`, the source of a file (an editor buffer's bytes for its file).
|
|
39
|
+
# @return a {Result}; `found` holds absolute paths in walk order, each file once, and never a seed.
|
|
40
|
+
def closure(seeds, covered:, read:)
|
|
41
|
+
seen = seeds.to_set { |path| real_path(path) }
|
|
42
|
+
queue = seeds.dup
|
|
43
|
+
found = []
|
|
44
|
+
missing = []
|
|
45
|
+
until queue.empty?
|
|
46
|
+
targets_of(queue.shift, read).each do |target|
|
|
47
|
+
next missing << target unless File.file?(target)
|
|
48
|
+
|
|
49
|
+
real = real_path(target)
|
|
50
|
+
next if covered.call(real) || !seen.add?(real)
|
|
51
|
+
|
|
52
|
+
found << target
|
|
53
|
+
queue << target
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
Result.new(found: found, missing: missing.uniq)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def real_path(path)
|
|
60
|
+
File.realpath(path)
|
|
61
|
+
rescue SystemCallError
|
|
62
|
+
File.absolute_path(path)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The `.rb` paths `path` names with a literal `require_relative`, absolute, whether or not they exist.
|
|
66
|
+
def targets_of(path, read)
|
|
67
|
+
source = read.call(path)
|
|
68
|
+
return [] if source.nil?
|
|
69
|
+
|
|
70
|
+
base = File.dirname(File.absolute_path(path))
|
|
71
|
+
literal_features(Prism.parse(source).value).map do |feature|
|
|
72
|
+
File.expand_path(feature.end_with?(".rb") ? feature : "#{feature}.rb", base)
|
|
73
|
+
end
|
|
74
|
+
rescue StandardError
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# In source order, so discovery meets the targets in the order Ruby loads them.
|
|
79
|
+
def literal_features(root)
|
|
80
|
+
calls = []
|
|
81
|
+
stack = [root]
|
|
82
|
+
until stack.empty?
|
|
83
|
+
node = stack.pop
|
|
84
|
+
calls << node if literal_feature(node)
|
|
85
|
+
stack.concat(node.compact_child_nodes)
|
|
86
|
+
end
|
|
87
|
+
calls.sort_by { |call| call.location.start_offset }.filter_map { |call| literal_feature(call) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def literal_feature(node)
|
|
91
|
+
return nil unless node.is_a?(Prism::CallNode) && node.name == :require_relative && node.receiver.nil?
|
|
92
|
+
|
|
93
|
+
arguments = node.arguments&.arguments
|
|
94
|
+
return nil unless arguments&.size == 1 && arguments.first.is_a?(Prism::StringNode)
|
|
95
|
+
|
|
96
|
+
arguments.first.unescaped
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -706,7 +706,11 @@ module Rigor
|
|
|
706
706
|
"Writes are in a singleton body — `def self.foo` or a `class << self` def — those track " \
|
|
707
707
|
"separately.",
|
|
708
708
|
"Writes are in a `def` inside an anonymous-class factory block (`Class.new do … end`, " \
|
|
709
|
-
"`Module.new`, `Struct.new`, `Data.define`): that body defines another class."
|
|
709
|
+
"`Module.new`, `Struct.new`, `Data.define`): that body defines another class.",
|
|
710
|
+
"The class descends from a plugin-declared per-invocation class (ADR-125; rigor-actionpack declares " \
|
|
711
|
+
"`ActionController::Base` / `ActionController::API`, rigor-actionmailer `ActionMailer::Base`), and the " \
|
|
712
|
+
"write and every conflicting write sit in different actions: public methods no callback macro or " \
|
|
713
|
+
"`helper_method` names. One action runs per instance."
|
|
710
714
|
],
|
|
711
715
|
suppression: "`# rigor:disable ivar-write-mismatch` on the offending write.",
|
|
712
716
|
severity_authored: :error,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
# Issue #2020 — what one file's analysis contributed to the run-scoped plugin tables ({Plugin::Base#disclose_once}
|
|
6
|
+
# and {Plugin::Base#emit_once}), kept beside the file's cached answer so an `--incremental` recheck that serves
|
|
7
|
+
# the file without analysing it can still report the rows a cold run reports.
|
|
8
|
+
#
|
|
9
|
+
# A replayed row must go stale with the inputs it is about, and the reads those inputs came from are credited to
|
|
10
|
+
# one file at most: a plugin memoises the input (a locale index, a routes table), so the first file to trigger
|
|
11
|
+
# the read is the only one credited with it, and a read in `#prepare` is credited to none. So each record also
|
|
12
|
+
# carries, under `:reads`, every row the registering plugin's boundary holds once the file is analysed,
|
|
13
|
+
# `#prepare`'s included. {IncrementalSession} keeps those rows per `[plugin id, key]`, not per file: when one goes
|
|
14
|
+
# stale it re-analyses one registering file and drops the key's replays, rather than re-analysing every file that
|
|
15
|
+
# registered it. Only inputs read through the boundary are covered.
|
|
16
|
+
module RunDisclosureCredit
|
|
17
|
+
ATTRIBUTION_KEY = :rigor_run_disclosure_attribution
|
|
18
|
+
private_constant :ATTRIBUTION_KEY
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Runs the block with run-scoped registrations attributed and returns the file's records — each a run-disclosure
|
|
23
|
+
# record tagged with its `plugin_id` and carrying the registering plugin's boundary rows as `:reads`, one per
|
|
24
|
+
# `[plugin id, key]` — frozen and Marshal-clean. A thread variable, as {Plugin::IoBoundary.attributing} uses, so a
|
|
25
|
+
# registration from inside an `Enumerator` still lands.
|
|
26
|
+
def collect
|
|
27
|
+
registrations = []
|
|
28
|
+
previous = Thread.current.thread_variable_get(ATTRIBUTION_KEY)
|
|
29
|
+
Thread.current.thread_variable_set(ATTRIBUTION_KEY, registrations)
|
|
30
|
+
begin
|
|
31
|
+
yield
|
|
32
|
+
ensure
|
|
33
|
+
Thread.current.thread_variable_set(ATTRIBUTION_KEY, previous)
|
|
34
|
+
end
|
|
35
|
+
credit(registrations)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Called by {Plugin::Base#disclose_once} and {Plugin::Base#emit_once} for every call, a repeat included.
|
|
39
|
+
def attribute(plugin, record)
|
|
40
|
+
Thread.current.thread_variable_get(ATTRIBUTION_KEY)&.push([plugin, record])
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def credit(registrations)
|
|
45
|
+
return [].freeze if registrations.empty?
|
|
46
|
+
|
|
47
|
+
reads = {}.compare_by_identity
|
|
48
|
+
records = registrations.map do |plugin, record|
|
|
49
|
+
rows = reads[plugin] ||= boundary_rows(plugin).freeze
|
|
50
|
+
record.merge(plugin_id: plugin_id(plugin), reads: rows).freeze
|
|
51
|
+
end
|
|
52
|
+
records.uniq { |record| identity(record) }.freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The de-duplication identity a record shares with every other registration of its key.
|
|
56
|
+
def identity(record)
|
|
57
|
+
[record[:plugin_id], record[:key]]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The record as the aggregator replays it, without the rows that decide whether it is stale.
|
|
61
|
+
def replayable(record)
|
|
62
|
+
record.except(:reads).freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The boundary's file and listing rows, read without triggering its lazy initializer: a plugin that never
|
|
66
|
+
# built one read nothing through it.
|
|
67
|
+
def boundary_rows(plugin)
|
|
68
|
+
boundary = plugin.instance_variable_get(:@io_boundary) or return []
|
|
69
|
+
descriptor = boundary.cache_descriptor
|
|
70
|
+
descriptor.files + descriptor.globs
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def plugin_id(plugin)
|
|
74
|
+
plugin.manifest.id
|
|
75
|
+
rescue StandardError
|
|
76
|
+
plugin.class.to_s
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|