rigortype 0.4.1 → 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/data/builtins/ruby_core/io.yml +1 -0
- data/data/builtins/ruby_core/proc.yml +4 -0
- data/data/builtins/ruby_core/random.yml +4 -0
- data/data/builtins/ruby_core/struct.yml +3 -0
- data/data/core_overlay/env.rbs +34 -0
- data/data/core_overlay/gc.rbs +50 -0
- data/data/core_overlay/hash.rbs +45 -0
- data/data/core_overlay/integer.rbs +31 -0
- data/data/core_overlay/io_buffer.rbs +27 -0
- data/data/core_overlay/kernel.rbs +40 -0
- data/data/core_overlay/match_data.rbs +28 -0
- data/data/core_overlay/module.rbs +51 -0
- data/data/core_overlay/object_space.rbs +29 -0
- data/data/core_overlay/proc.rbs +45 -0
- data/data/core_overlay/range.rbs +40 -0
- data/data/core_overlay/source_range.rbs +78 -0
- data/data/core_overlay/string.rbs +86 -0
- data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
- data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
- data/docs/handbook/01-getting-started.md +3 -3
- data/docs/handbook/06-classes.md +3 -1
- data/docs/handbook/11-sig-gen.md +18 -5
- data/docs/handbook/appendix-liskov.md +5 -0
- data/docs/manual/02-cli-reference.md +19 -1
- data/docs/manual/03-configuration.md +7 -5
- data/docs/manual/04-diagnostics.md +2 -1
- data/docs/manual/05-inspecting-types.md +50 -0
- data/docs/manual/06-baseline.md +2 -0
- data/docs/manual/07-plugins.md +1 -1
- 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 +41 -4
- data/docs/manual/plugins/README.md +6 -1
- data/docs/manual/plugins/rigor-activerecord.md +21 -0
- data/docs/manual/plugins/rigor-alba.md +60 -0
- data/docs/manual/plugins/rigor-minitest.md +22 -4
- data/docs/manual/plugins/rigor-typelizer.md +71 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
- data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +11 -0
- data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
- data/lib/rigor/analysis/check_rules.rb +292 -59
- data/lib/rigor/analysis/dependency_recorder.rb +15 -0
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
- data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
- data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
- data/lib/rigor/analysis/erb_template_detector.rb +2 -1
- data/lib/rigor/analysis/incremental.rb +29 -9
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
- data/lib/rigor/analysis/incremental_run_slot.rb +6 -4
- data/lib/rigor/analysis/incremental_session.rb +103 -23
- 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 +241 -37
- data/lib/rigor/analysis/reachability/scan.rb +490 -77
- data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
- data/lib/rigor/analysis/rule_catalog.rb +43 -0
- data/lib/rigor/analysis/run_cache_key.rb +16 -1
- 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 +113 -6
- data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -19
- data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
- data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
- data/lib/rigor/analysis/runner.rb +62 -13
- data/lib/rigor/analysis/worker_session.rb +8 -5
- data/lib/rigor/cache/engine_source.rb +21 -7
- data/lib/rigor/cache/incremental_snapshot.rb +55 -5
- data/lib/rigor/cli/annotate_command.rb +5 -0
- data/lib/rigor/cli/baseline_command.rb +7 -18
- data/lib/rigor/cli/check_command.rb +140 -40
- 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/trace_command.rb +5 -0
- data/lib/rigor/cli/type_of_command.rb +6 -3
- data/lib/rigor/cli/unused_command.rb +166 -27
- data/lib/rigor/cli.rb +39 -5
- data/lib/rigor/configuration/severity_profile.rb +6 -0
- data/lib/rigor/configuration.rb +61 -2
- data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
- data/lib/rigor/environment/gated_signature_guard.rb +43 -0
- data/lib/rigor/environment/gated_signature_plan.rb +339 -0
- 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_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +316 -14
- data/lib/rigor/environment/required_features.rb +208 -0
- data/lib/rigor/environment.rb +33 -8
- data/lib/rigor/inference/acceptance.rb +28 -3
- data/lib/rigor/inference/builtins/cruby_definers.rb +78 -0
- data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
- data/lib/rigor/inference/captured_locals.rb +43 -3
- data/lib/rigor/inference/def_node_resolver.rb +23 -1
- data/lib/rigor/inference/expression_typer.rb +261 -29
- data/lib/rigor/inference/global_write_census.rb +7 -4
- data/lib/rigor/inference/guard_rebinding.rb +1 -1
- data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
- data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +286 -0
- data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
- data/lib/rigor/inference/in_effect_refinements.rb +256 -0
- data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
- data/lib/rigor/inference/key_presence_guard.rb +303 -0
- data/lib/rigor/inference/macro_block_self_type.rb +59 -12
- data/lib/rigor/inference/match_rebinding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
- data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
- data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
- data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
- data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
- 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/method_folding.rb +2 -1
- data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +247 -28
- data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
- 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 +96 -14
- data/lib/rigor/inference/module_function_state.rb +2 -1
- data/lib/rigor/inference/narrowing.rb +20 -6
- data/lib/rigor/inference/object_mixins.rb +180 -0
- data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
- data/lib/rigor/inference/project_patched_methods.rb +12 -7
- data/lib/rigor/inference/project_patched_scanner.rb +108 -26
- data/lib/rigor/inference/refine_census.rb +239 -0
- data/lib/rigor/inference/refine_self.rb +317 -0
- data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +475 -0
- data/lib/rigor/inference/refined_dispatch.rb +320 -0
- data/lib/rigor/inference/repeated_or_writes.rb +2 -0
- data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
- data/lib/rigor/inference/scope_indexer.rb +827 -65
- data/lib/rigor/inference/statement_evaluator.rb +294 -51
- data/lib/rigor/inference/string_mutation.rb +9 -3
- data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
- data/lib/rigor/inference/untyped_rejoin.rb +75 -0
- data/lib/rigor/inference/version_guard.rb +19 -10
- data/lib/rigor/language_server/project_context.rb +87 -5
- data/lib/rigor/language_server/server.rb +18 -0
- 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/plugin/inflector.rb +1 -1
- data/lib/rigor/plugin/io_boundary.rb +42 -3
- data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
- data/lib/rigor/project_environment.rb +48 -6
- data/lib/rigor/protection/mutation_cache.rb +3 -2
- data/lib/rigor/protection/mutator.rb +1 -1
- data/lib/rigor/reflection/constant_ancestors.rb +127 -11
- data/lib/rigor/reflection/constant_path.rb +13 -2
- data/lib/rigor/reflection.rb +40 -8
- data/lib/rigor/scope/discovery_index.rb +40 -1
- data/lib/rigor/scope/resolution_chain.rb +159 -11
- data/lib/rigor/scope.rb +211 -21
- data/lib/rigor/sig_gen/classification.rb +4 -1
- data/lib/rigor/sig_gen/generator.rb +100 -10
- data/lib/rigor/sig_gen/method_candidate.rb +2 -2
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
- data/lib/rigor/source/alias_names.rb +52 -0
- data/lib/rigor/source/literals.rb +3 -2
- data/lib/rigor/source/symbol_name.rb +20 -0
- data/lib/rigor/type/combinator.rb +34 -2
- data/lib/rigor/type/float_range.rb +4 -1
- data/lib/rigor/type/nominal.rb +6 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
- data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -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/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
- data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
- data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
- data/plugins/rigor-alba/sig/alba.rbs +4 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
- data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
- data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
- data/sig/rigor/plugin/io_boundary.rbs +3 -0
- data/sig/rigor/scope.rbs +30 -4
- data/sig/rigor/source.rbs +4 -0
- data/sig/rigor.rbs +9 -4
- data/skills/rigor-project-init/references/02-configure.md +2 -2
- metadata +83 -1
|
@@ -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
|
|
@@ -5,6 +5,121 @@ require_relative "scan"
|
|
|
5
5
|
module Rigor
|
|
6
6
|
module Analysis
|
|
7
7
|
module Reachability
|
|
8
|
+
# ADR-102 WD4 — how {Graph} reads a `constantize` / `const_get` site: a literal name becomes a reference
|
|
9
|
+
# looked up where Ruby looks it up, and an interpolated one on a known receiver is anchored there.
|
|
10
|
+
module DynamicLookup
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# A literal-argument `"Foo::Bar".constantize` names its constant exactly, so it is a REFERENCE, not an
|
|
14
|
+
# unknown. Keeping this distinct from {Graph#tainted} is what stops the tier being a blanket namespace
|
|
15
|
+
# poison — Rigor knows the argument's shape, and a type-free indexer does not.
|
|
16
|
+
#
|
|
17
|
+
# A literal `const_get` on a known receiver is looked up where Ruby looks it up (#1761): the receiver,
|
|
18
|
+
# then its ancestors unless the call passes `inherit = false`, then the top level unless it does. A
|
|
19
|
+
# receiver holding the name is the answer — resolving it anywhere else too would make a dead same-named
|
|
20
|
+
# constant reachable; past the receiver see {#literal_targets}. A receiver that does not resolve as a
|
|
21
|
+
# whole is unknown — `Outer::Missing` peeled to `Outer` may alias anything, and `const_get` never
|
|
22
|
+
# searches `Outer` — so it falls back to the top level as an unknown receiver does.
|
|
23
|
+
#
|
|
24
|
+
# The reference carries the referring file's role (WD8): a spec's `const_get("Foo")` keeps `Foo`
|
|
25
|
+
# test-reachable, as its written `Foo` would, rather than making it live in production.
|
|
26
|
+
def literal_dynamic_references(dynamic_uses)
|
|
27
|
+
dynamic_uses.flat_map do |use|
|
|
28
|
+
next [] if use.name.nil?
|
|
29
|
+
|
|
30
|
+
name = use.name.delete_prefix("::")
|
|
31
|
+
targets = use.within ? literal_targets(use, name) : [nil]
|
|
32
|
+
targets.map do |target|
|
|
33
|
+
Scan::Reference.new(as_written: target || name, nesting: [].freeze, from: nil,
|
|
34
|
+
role: use.role, path: use.path, line: use.line,
|
|
35
|
+
rooted: !target.nil? || use.name.start_with?("::"))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The declarations a literal `name` can name when looked up from `use.within`, a member's owner peeled
|
|
41
|
+
# as {#resolve} peels it; nil stands for the top level. The receiver declaring it is the answer. Past
|
|
42
|
+
# the receiver the walk only approximates Ruby: an ancestor named as written may resolve elsewhere
|
|
43
|
+
# (`class Api::Thing < Base` inherits `::Base`, not `Api::Base`), so every scope an ancestor could be
|
|
44
|
+
# that declares the name is kept, and the top level with them. Keeping a dead constant reachable is
|
|
45
|
+
# the cheaper mistake than listing a live one. A receiver that does not resolve is the top level; with
|
|
46
|
+
# `inherit = false` a resolved receiver that does not declare the name is nothing (Ruby raises
|
|
47
|
+
# `NameError` there).
|
|
48
|
+
def literal_targets(use, name)
|
|
49
|
+
receiver = use.receiver { |ref| resolve_ref(ref) }
|
|
50
|
+
return [nil] if receiver.nil?
|
|
51
|
+
|
|
52
|
+
local = @shadows[use.path]
|
|
53
|
+
own = declared_member(receiver, name, local)
|
|
54
|
+
return [own] if own
|
|
55
|
+
return [] unless use.inherit
|
|
56
|
+
|
|
57
|
+
plausible_ancestors(receiver).filter_map { |scope| declared_member(scope, name, local) }.uniq + [nil]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `scope::name`, or the longest declared prefix of it, when one is declared.
|
|
61
|
+
def declared_member(scope, name, local)
|
|
62
|
+
segments = name.split("::")
|
|
63
|
+
segments.length.downto(1) do |count|
|
|
64
|
+
candidate = "#{scope}::#{segments.first(count).join('::')}"
|
|
65
|
+
return candidate if declared?(candidate, nil, local)
|
|
66
|
+
end
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Every declaration an ancestor of `fqn` could be: each as-written superclass or mixin resolved at
|
|
71
|
+
# every enclosing scope that declares it, not only the innermost, and transitively.
|
|
72
|
+
def plausible_ancestors(fqn)
|
|
73
|
+
(@plausible_ancestors ||= {})[fqn] ||= plausible_ancestor_walk(fqn)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def plausible_ancestor_walk(fqn)
|
|
77
|
+
seen = Set[fqn]
|
|
78
|
+
queue = [fqn]
|
|
79
|
+
out = []
|
|
80
|
+
until queue.empty?
|
|
81
|
+
current = queue.shift
|
|
82
|
+
@by_fqn.fetch(current, []).each do |decl|
|
|
83
|
+
(decl.includes + [decl.superclass]).compact.each do |raw|
|
|
84
|
+
plausible_resolutions(current, raw).each do |resolved|
|
|
85
|
+
next if seen.include?(resolved)
|
|
86
|
+
|
|
87
|
+
seen << resolved
|
|
88
|
+
out << resolved
|
|
89
|
+
queue << resolved
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
out.freeze
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def plausible_resolutions(subclass_fqn, raw)
|
|
98
|
+
segments = subclass_fqn.split("::")
|
|
99
|
+
(segments.length - 1).downto(0).filter_map do |i|
|
|
100
|
+
candidate = (segments[0, i] + [raw]).join("::")
|
|
101
|
+
candidate if candidate != subclass_fqn && @by_fqn.key?(candidate)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The namespaces a `const_get` on `anchors` searches before the top level: the receiver, then, with the
|
|
106
|
+
# default `inherit = true`, every scope its superclass chain and mixins could be (#1761) — a taint is
|
|
107
|
+
# safe to over-reach.
|
|
108
|
+
def receiver_scopes(use, anchors)
|
|
109
|
+
return anchors unless use.inherit
|
|
110
|
+
|
|
111
|
+
anchors.flat_map { |anchor| [anchor, *plausible_ancestors(anchor)] }.uniq
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# An interpolated site on a known receiver, with the receiver resolved into absolute `prefix`es.
|
|
115
|
+
def anchored(use)
|
|
116
|
+
return [use] if use.within.nil? || use.prefix.nil?
|
|
117
|
+
|
|
118
|
+
anchors = use.anchors { |ref| resolve_ref(ref) }
|
|
119
|
+
receiver_scopes(use, anchors).map { |scope| use.with(prefix: "#{scope}::#{use.prefix}", within: nil) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
8
123
|
# ADR-102 — the cross-file half: resolves every as-written reference to a declaration, then marks from the
|
|
9
124
|
# root set. Pure data; no engine coupling, so `rigor check`'s diagnostic stream is untouched by
|
|
10
125
|
# construction rather than by a gate (WD1).
|
|
@@ -15,6 +130,8 @@ module Rigor
|
|
|
15
130
|
# believe in. The two walks are separate implementations (this one is name-level and needs no types), so
|
|
16
131
|
# `spec/rigor/analysis/reachability/graph_spec.rb` pins them to the same answers on shared fixtures.
|
|
17
132
|
class Graph
|
|
133
|
+
include DynamicLookup
|
|
134
|
+
|
|
18
135
|
Candidate = Data.define(:fqn, :path, :line)
|
|
19
136
|
|
|
20
137
|
# `test_only` is its own list, not a flag on `candidates`: a candidate is by definition unreachable, so
|
|
@@ -35,15 +152,21 @@ module Rigor
|
|
|
35
152
|
# a reopened gem or stdlib class must never be a candidate (WD6). Defaults to "nothing is foreign".
|
|
36
153
|
# @param dynamic_uses — sites where a constant is reached by name at runtime.
|
|
37
154
|
# A literal-argument site contributes a real reference; a dynamic one taints a namespace (WD4).
|
|
38
|
-
|
|
155
|
+
# @param shadows — declarations made in files outside the analysed paths. They are never nodes, but a
|
|
156
|
+
# reference written in the SAME file resolves to them first, as Ruby would (#1732, see {#resolve_ref}).
|
|
157
|
+
def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], shadows: [],
|
|
158
|
+
foreign: ->(_fqn) { false })
|
|
39
159
|
@declarations = declarations
|
|
40
|
-
@dynamic_uses = dynamic_uses
|
|
41
|
-
@references = references + literal_dynamic_references(dynamic_uses)
|
|
42
160
|
@root_fqns = root_fqns.to_set
|
|
43
161
|
@foreign = foreign
|
|
44
162
|
@by_fqn = declarations.group_by(&:fqn)
|
|
163
|
+
@shadows = shadows.group_by(&:path).transform_values { |decls| decls.to_set(&:fqn) }
|
|
164
|
+
@shadows.default = Set.new.freeze
|
|
45
165
|
@owned = @by_fqn.keys.reject { |fqn| @foreign.call(fqn) }.to_set
|
|
46
166
|
@ancestors = {}
|
|
167
|
+
@references = references + literal_dynamic_references(dynamic_uses)
|
|
168
|
+
# `Foo.const_get("V#{k}")` bounds its reach by `Foo`, a reference like any other (#1734).
|
|
169
|
+
@dynamic_uses = dynamic_uses.flat_map { |use| anchored(use) }
|
|
47
170
|
end
|
|
48
171
|
|
|
49
172
|
def report
|
|
@@ -51,39 +174,36 @@ module Rigor
|
|
|
51
174
|
production = walk(edges, seeds: production_seeds, roles: %i[production task config])
|
|
52
175
|
reachable = walk(edges, seeds: production_seeds | test_seeds, roles: %i[production task config test])
|
|
53
176
|
unreached = @owned - reachable
|
|
54
|
-
|
|
177
|
+
# A namespace is excused by a member PRODUCTION reaches. One whose members only tests reach is dead
|
|
178
|
+
# production code like them, so it joins them under test-only rather than vanishing from every
|
|
179
|
+
# bucket (#1732).
|
|
180
|
+
namespaces = namespace_only(unreached, production)
|
|
181
|
+
test_only = (reachable - production) | namespace_only(unreached - namespaces, reachable)
|
|
182
|
+
unreached -= namespaces | test_only
|
|
183
|
+
# A namespace a test names (`Ns::CONST`) while production reaches a declaration under it is live in
|
|
184
|
+
# production for the same reason an unreached one is not dead (#1732).
|
|
185
|
+
live_namespaces = namespace_only(test_only, production)
|
|
186
|
+
test_only -= live_namespaces
|
|
55
187
|
# The data-file demotion applies to BOTH buckets it can speak to, which is what the tier
|
|
56
188
|
# contract says and what the implementation had narrowed (#370). See {#tainted}.
|
|
57
|
-
|
|
58
|
-
undecidable =
|
|
59
|
-
|
|
60
|
-
|
|
189
|
+
undecidable = tainted(unreached).merge(tainted(test_only))
|
|
190
|
+
undecidable = spread_undecidable(edges, undecidable, unreached | test_only,
|
|
191
|
+
through: namespaces | live_namespaces)
|
|
192
|
+
build_report(edges:, reachable:, unreached:, namespaces:, test_only:, undecidable:)
|
|
61
193
|
end
|
|
62
194
|
|
|
63
195
|
private
|
|
64
196
|
|
|
65
197
|
def build_report(edges:, reachable:, unreached:, namespaces:, test_only:, undecidable:)
|
|
66
198
|
demoted = undecidable.keys.to_set
|
|
67
|
-
|
|
68
|
-
|
|
199
|
+
# A test-only namespace counts as reachable, like the members it is listed with.
|
|
200
|
+
Report.new(declared: @owned.size, reachable: (reachable | test_only).size,
|
|
201
|
+
candidates: rows(unreached - demoted),
|
|
69
202
|
undecidable: undecidable.map { |fqn, reason| undecidable_row(fqn, reason) }.freeze,
|
|
70
203
|
test_only: rows(test_only - demoted),
|
|
71
204
|
namespaces: namespaces.size, roots: production_seeds.size, edges: edges.size)
|
|
72
205
|
end
|
|
73
206
|
|
|
74
|
-
# A literal-argument `"Foo::Bar".constantize` names its constant exactly, so it is a REFERENCE, not an
|
|
75
|
-
# unknown. Keeping this distinct from the taint below is what stops the tier being a blanket namespace
|
|
76
|
-
# poison — Rigor knows the argument's shape, and a type-free indexer does not.
|
|
77
|
-
def literal_dynamic_references(dynamic_uses)
|
|
78
|
-
dynamic_uses.filter_map do |use|
|
|
79
|
-
next if use.name.nil?
|
|
80
|
-
|
|
81
|
-
Scan::Reference.new(as_written: use.name.sub(/\A::/, ""), nesting: [].freeze, from: nil,
|
|
82
|
-
role: :production, path: use.path, line: use.line,
|
|
83
|
-
rooted: use.name.start_with?("::"))
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
207
|
# `{fqn => reason}` for every declaration in `fqns` a dynamic site could still be naming. A site with a
|
|
88
208
|
# literal prefix taints that namespace and everything under it; a site with no prefix at all cannot be
|
|
89
209
|
# bounded, so it taints nothing rather than everything — poisoning the whole project would empty the
|
|
@@ -109,6 +229,41 @@ module Rigor
|
|
|
109
229
|
end
|
|
110
230
|
end
|
|
111
231
|
|
|
232
|
+
# Whatever an undecidable declaration reaches is undecidable too (#1720 review). `class Sub < Base` with
|
|
233
|
+
# `Sub` reachable through `"Sub#{x}".constantize` left `Base` a DEFINITE candidate, and acting on that row
|
|
234
|
+
# deletes the base of a class that may well be live. Only declarations that would otherwise be
|
|
235
|
+
# reported — unreached or test-only — are moved; the reason names the declaration they hang from.
|
|
236
|
+
#
|
|
237
|
+
# The spread passes `through` a declaration hidden as a namespace without listing it: a service hidden
|
|
238
|
+
# only because something names a constant nested in it (`Svc::Error`) still carries the undecidable
|
|
239
|
+
# evidence on to what its body names. Stopping there left the next service down under "reachable only
|
|
240
|
+
# from tests" (#1732).
|
|
241
|
+
def spread_undecidable(edges, undecidable, reportable, through:)
|
|
242
|
+
return undecidable if undecidable.empty?
|
|
243
|
+
|
|
244
|
+
out = Hash.new { |h, k| h[k] = [] }
|
|
245
|
+
edges.each { |from, to, _role| out[from] << to }
|
|
246
|
+
result = undecidable.dup
|
|
247
|
+
passed = Set.new
|
|
248
|
+
queue = undecidable.keys
|
|
249
|
+
until queue.empty?
|
|
250
|
+
from = queue.shift
|
|
251
|
+
out[from].each do |target|
|
|
252
|
+
next if result.key?(target) || passed.include?(target)
|
|
253
|
+
|
|
254
|
+
if reportable.include?(target)
|
|
255
|
+
result[target] = "reachable from #{from}, which cannot be decided"
|
|
256
|
+
elsif through.include?(target)
|
|
257
|
+
passed << target
|
|
258
|
+
else
|
|
259
|
+
next
|
|
260
|
+
end
|
|
261
|
+
queue << target
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
result
|
|
265
|
+
end
|
|
266
|
+
|
|
112
267
|
def undecidable_row(fqn, reason)
|
|
113
268
|
site = @by_fqn.fetch(fqn).first
|
|
114
269
|
Undecidable.new(fqn: fqn, path: site.path, line: site.line, reason: reason)
|
|
@@ -132,14 +287,32 @@ module Rigor
|
|
|
132
287
|
# Seeds that make a declaration live in PRODUCTION: named entry points, plus anything referenced at
|
|
133
288
|
# file level by a non-test file (file-level code runs on load, so its target is live).
|
|
134
289
|
def production_seeds
|
|
135
|
-
@production_seeds ||= (@root_fqns & @owned) | seeds_from { |ref| ref.
|
|
290
|
+
@production_seeds ||= (@root_fqns & @owned) | seeds_from { |ref| source(ref).nil? && ref.role != :test }
|
|
136
291
|
end
|
|
137
292
|
|
|
138
293
|
# Seeds that make a declaration live only through TEST code. Kept separate from production seeds
|
|
139
294
|
# rather than folded in: a spec's file-level `Foo.new` would otherwise promote `Foo` to a root and
|
|
140
295
|
# erase the very distinction WD8 exists to report.
|
|
141
296
|
def test_seeds
|
|
142
|
-
@test_seeds ||= seeds_from { |ref| ref.
|
|
297
|
+
@test_seeds ||= seeds_from { |ref| source(ref).nil? && ref.role == :test }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# The node an edge leaves: the owned declaration whose code holds the reference, with a declaration-header
|
|
301
|
+
# reference (`class Sub < Base`) credited to `Sub` (#1720). Only an owned declaration from `paths:` is a
|
|
302
|
+
# node, though. A class an initializer, `config/application.rb` or a spec helper declares, or a reopened
|
|
303
|
+
# gem class (WD6), is outside the declaration set, so the walk can never start from it and an edge
|
|
304
|
+
# leaving it would silently drop the evidence. Falling back to the enclosing nesting (#1724) did not help
|
|
305
|
+
# either: `module MyApp; class Application` and a spec's `module Outer; class Fake < OBase` fall back to a
|
|
306
|
+
# module that is never reached itself, or is not a node at all (#1732).
|
|
307
|
+
#
|
|
308
|
+
# Such a reference counts as file-level code of its file instead, in that file's role: a spec helper's
|
|
309
|
+
# reference keeps its target test-reachable, an initializer's keeps it live in production. A class header
|
|
310
|
+
# and a class body's own statements run when the file loads; a method body is read the same way because
|
|
311
|
+
# whether it runs is not decidable from a declaration this report does not own, and the other reading
|
|
312
|
+
# would report live code as dead. What the reference NAMES is still resolved against its own file's
|
|
313
|
+
# declarations first (see {#resolve_ref}).
|
|
314
|
+
def source(ref)
|
|
315
|
+
@owned.include?(ref.from) ? ref.from : nil
|
|
143
316
|
end
|
|
144
317
|
|
|
145
318
|
def seeds_from
|
|
@@ -147,7 +320,7 @@ module Rigor
|
|
|
147
320
|
@references.each do |ref|
|
|
148
321
|
next unless yield(ref)
|
|
149
322
|
|
|
150
|
-
target =
|
|
323
|
+
target = resolve_ref(ref)
|
|
151
324
|
set << target if target && @owned.include?(target)
|
|
152
325
|
end
|
|
153
326
|
set
|
|
@@ -156,14 +329,39 @@ module Rigor
|
|
|
156
329
|
# `[from_fqn_or_nil, to_fqn, role]` for every reference that resolves to an owned declaration.
|
|
157
330
|
def resolved_edges
|
|
158
331
|
@resolved_edges ||= @references.filter_map do |ref|
|
|
159
|
-
target =
|
|
332
|
+
target = resolve_ref(ref)
|
|
160
333
|
next unless target && @owned.include?(target)
|
|
161
|
-
next if ref.from == target # a declaration referencing itself is not evidence of use
|
|
162
334
|
|
|
163
|
-
|
|
335
|
+
from = source(ref)
|
|
336
|
+
next if from == target # a declaration referencing itself is not evidence of use
|
|
337
|
+
|
|
338
|
+
[from, target, ref.role]
|
|
164
339
|
end
|
|
165
340
|
end
|
|
166
341
|
|
|
342
|
+
# A declaration-header reference never names the declaration it belongs to: `module Api; class User <
|
|
343
|
+
# User; end; end` reads `User` before `Api::User` exists, so Ruby answers `::User`. Every other reference
|
|
344
|
+
# resolves as written, and one that lands on its own declaration is dropped as a self-reference.
|
|
345
|
+
#
|
|
346
|
+
# A class its own file declares outside `paths:` shadows a same-named owned one, as Ruby's lookup does:
|
|
347
|
+
# `LegacyThing` inside a migration's local `class LegacyThing < ActiveRecord::Base` stub names the stub,
|
|
348
|
+
# not the app's model. The answer is then not an owned node and the reference is dropped. Only the
|
|
349
|
+
# referring file's own declarations count: a spec support file's stub is loaded with the specs, never
|
|
350
|
+
# with production, so letting it shadow other files could hide a production reference (#1732).
|
|
351
|
+
def resolve_ref(ref)
|
|
352
|
+
local = @shadows[ref.path]
|
|
353
|
+
target = resolve(ref.as_written, ref.nesting, rooted: ref.rooted, local: local)
|
|
354
|
+
return target unless target && target == ref.from && header?(ref)
|
|
355
|
+
|
|
356
|
+
resolve(ref.as_written, ref.nesting, rooted: ref.rooted, exclude: target, local: local)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Only a header reference is credited to something other than the scope it is written in (see
|
|
360
|
+
# {Scan::Reference}).
|
|
361
|
+
def header?(ref)
|
|
362
|
+
ref.from != (ref.nesting.empty? ? nil : ref.nesting.join("::"))
|
|
363
|
+
end
|
|
364
|
+
|
|
167
365
|
# Mark-and-sweep, not reference counting: an edge only propagates if its SOURCE is itself reachable, so
|
|
168
366
|
# a cluster of mutually-referencing dead classes stays dead (ADR-102 WD2).
|
|
169
367
|
#
|
|
@@ -198,18 +396,19 @@ module Rigor
|
|
|
198
396
|
# Ruby's constant lookup at name granularity: `Module.nesting` innermost first, then the ancestors of
|
|
199
397
|
# the innermost cresting scope (#354), then the bare name. When `rooted: true`, lexical nesting and
|
|
200
398
|
# ancestors are skipped, answering the top-level declaration directly (#625).
|
|
201
|
-
|
|
399
|
+
# `exclude` names a declaration the answer must not be (see {#resolve_ref}).
|
|
400
|
+
def resolve(as_written, nesting, rooted: false, exclude: nil, local: nil)
|
|
202
401
|
if rooted
|
|
203
|
-
return as_written if
|
|
402
|
+
return as_written if declared?(as_written, exclude, local)
|
|
204
403
|
|
|
205
404
|
idx = as_written.rindex("::")
|
|
206
|
-
return idx ? resolve(as_written[0, idx], nesting, rooted: true) : nil
|
|
405
|
+
return idx ? resolve(as_written[0, idx], nesting, rooted: true, exclude: exclude, local: local) : nil
|
|
207
406
|
end
|
|
208
407
|
|
|
209
408
|
walker = nesting.dup
|
|
210
409
|
until walker.empty?
|
|
211
410
|
candidate = (walker + [as_written]).join("::")
|
|
212
|
-
return candidate if
|
|
411
|
+
return candidate if declared?(candidate, exclude, local)
|
|
213
412
|
|
|
214
413
|
walker.pop
|
|
215
414
|
end
|
|
@@ -217,11 +416,11 @@ module Rigor
|
|
|
217
416
|
unless nesting.empty?
|
|
218
417
|
ancestor_scopes(nesting.join("::")).each do |ancestor|
|
|
219
418
|
candidate = "#{ancestor}::#{as_written}"
|
|
220
|
-
return candidate if
|
|
419
|
+
return candidate if declared?(candidate, exclude, local)
|
|
221
420
|
end
|
|
222
421
|
end
|
|
223
422
|
|
|
224
|
-
return as_written if
|
|
423
|
+
return as_written if declared?(as_written, exclude, local)
|
|
225
424
|
|
|
226
425
|
# `Scope::DiscoveryIndex::EMPTY` names a constant INSIDE a class, and reading it is a use of that
|
|
227
426
|
# class — but the leaf is not itself a declaration, so the reference would resolve to nothing and
|
|
@@ -229,7 +428,11 @@ module Rigor
|
|
|
229
428
|
# on the first run of this report against Rigor's own `lib`). Peel the trailing segment and retry:
|
|
230
429
|
# a reference to a member is a reference to its owner.
|
|
231
430
|
idx = as_written.rindex("::")
|
|
232
|
-
idx ? resolve(as_written[0, idx], nesting) : nil
|
|
431
|
+
idx ? resolve(as_written[0, idx], nesting, exclude: exclude, local: local) : nil
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def declared?(fqn, exclude, local)
|
|
435
|
+
fqn != exclude && (@by_fqn.key?(fqn) || local&.include?(fqn))
|
|
233
436
|
end
|
|
234
437
|
|
|
235
438
|
# Breadth-first over superclass + included modules, mixins first, terminating on a cycle. As-written
|
|
@@ -260,7 +463,8 @@ module Rigor
|
|
|
260
463
|
segments = subclass_fqn.split("::")
|
|
261
464
|
(segments.length - 1).downto(0) do |i|
|
|
262
465
|
candidate = (segments[0, i] + [raw]).join("::")
|
|
263
|
-
|
|
466
|
+
# `class Api::User < User` cannot inherit from itself; the name was read before it existed.
|
|
467
|
+
return candidate if candidate != subclass_fqn && @by_fqn.key?(candidate)
|
|
264
468
|
end
|
|
265
469
|
nil
|
|
266
470
|
end
|