rigortype 0.3.5 → 0.3.7
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 +5 -5
- data/data/builtins/ruby_core/date.yml +6 -3
- data/data/builtins/ruby_core/enumerable.yml +1 -0
- data/data/builtins/ruby_core/exception.yml +2 -1
- data/data/builtins/ruby_core/file.yml +28 -14
- data/data/builtins/ruby_core/hash.yml +5 -2
- data/data/builtins/ruby_core/io.yml +33 -16
- data/data/builtins/ruby_core/random.yml +2 -1
- data/data/builtins/ruby_core/re.yml +6 -3
- data/data/builtins/ruby_core/struct.yml +2 -0
- data/data/builtins/ruby_core/time.yml +2 -1
- data/data/gem_overlay/activesupport/core_ext.rbs +442 -11
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +29 -16
- data/docs/manual/03-configuration.md +1 -1
- data/docs/manual/04-diagnostics.md +12 -0
- data/docs/manual/05-inspecting-types.md +20 -8
- data/docs/manual/07-plugins.md +4 -0
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +1 -1
- data/docs/manual/12-caching.md +1 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +19 -12
- data/docs/manual/README.md +2 -2
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-actionpack.md +61 -0
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +78 -5
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- data/docs/manual/plugins/rigor-sidekiq.md +22 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
- data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
- data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
- data/lib/rigor/analysis/check_rules.rb +383 -48
- data/lib/rigor/analysis/crash_signature.rb +116 -0
- data/lib/rigor/analysis/dependency_recorder.rb +18 -3
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/incremental.rb +28 -0
- data/lib/rigor/analysis/incremental_session.rb +44 -9
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
- data/lib/rigor/analysis/reachability/graph.rb +44 -16
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +27 -7
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/result.rb +32 -0
- data/lib/rigor/analysis/run_cache_key.rb +53 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
- data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
- data/lib/rigor/analysis/runner.rb +298 -38
- data/lib/rigor/analysis/worker_session.rb +10 -1
- data/lib/rigor/bleeding_edge.rb +7 -6
- data/lib/rigor/builtins/hkt_builtins.rb +45 -6
- data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
- data/lib/rigor/builtins/static_return_refinements.rb +20 -1
- data/lib/rigor/cache/descriptor.rb +42 -2
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cache/incremental_snapshot.rb +40 -3
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
- data/lib/rigor/cache/store.rb +10 -1
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +28 -24
- data/lib/rigor/cli/coverage_mutation.rb +13 -17
- data/lib/rigor/cli/coverage_scan.rb +47 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +27 -2
- data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
- data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
- data/lib/rigor/cli/fused_protection_report.rb +23 -2
- data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
- data/lib/rigor/cli/mutation_protection_report.rb +31 -2
- data/lib/rigor/cli/sig_gen_command.rb +18 -0
- data/lib/rigor/cli/type_of_command.rb +188 -41
- data/lib/rigor/cli/type_of_renderer.rb +72 -3
- data/lib/rigor/cli/type_scan_command.rb +10 -1
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +22 -9
- data/lib/rigor/configuration.rb +7 -4
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/plugin_facts.rb +1 -1
- data/lib/rigor/effects/scanner.rb +2 -3
- data/lib/rigor/effects/signature_sources.rb +13 -2
- data/lib/rigor/effects/snapshot.rb +53 -21
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
- data/lib/rigor/environment/lockfile_resolver.rb +15 -5
- data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
- data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
- data/lib/rigor/environment/rbs_loader.rb +525 -46
- data/lib/rigor/environment.rb +38 -15
- data/lib/rigor/inference/acceptance.rb +172 -20
- data/lib/rigor/inference/captured_locals.rb +62 -0
- data/lib/rigor/inference/content_join.rb +347 -0
- data/lib/rigor/inference/def_handle.rb +15 -3
- data/lib/rigor/inference/def_node_resolver.rb +62 -3
- data/lib/rigor/inference/expression_typer.rb +1020 -141
- data/lib/rigor/inference/fork_map.rb +6 -1
- data/lib/rigor/inference/hkt_reducer.rb +12 -1
- data/lib/rigor/inference/hkt_registry.rb +46 -0
- data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
- data/lib/rigor/inference/index_write_widening.rb +48 -0
- data/lib/rigor/inference/indexed_narrowing.rb +14 -0
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
- data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
- data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
- data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
- data/lib/rigor/inference/method_dispatcher.rb +155 -14
- data/lib/rigor/inference/method_parameter_binder.rb +3 -3
- data/lib/rigor/inference/mutation_widening.rb +244 -174
- data/lib/rigor/inference/narrowing.rb +331 -27
- data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
- data/lib/rigor/inference/pre_eval_constants.rb +15 -0
- data/lib/rigor/inference/precision_scanner.rb +5 -2
- data/lib/rigor/inference/project_patched_scanner.rb +2 -1
- data/lib/rigor/inference/rbs_type_translator.rb +89 -39
- data/lib/rigor/inference/scope_indexer.rb +1493 -140
- data/lib/rigor/inference/statement_evaluator.rb +276 -91
- data/lib/rigor/inference/struct_fold_safety.rb +186 -27
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- data/lib/rigor/inference/version_guard.rb +229 -0
- data/lib/rigor/language_server/selection_range_provider.rb +1 -1
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/inflector.rb +14 -5
- data/lib/rigor/plugin/io_boundary.rb +107 -4
- data/lib/rigor/plugin/loader.rb +14 -0
- data/lib/rigor/protection/analysis_guard.rb +60 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
- data/lib/rigor/protection/discovery_seed.rb +1 -1
- data/lib/rigor/protection/measurement_integrity.rb +36 -0
- data/lib/rigor/protection/mutation_scanner.rb +61 -6
- data/lib/rigor/protection/mutator.rb +121 -18
- data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
- data/lib/rigor/reflection.rb +182 -30
- data/lib/rigor/scope/discovery_index.rb +39 -1
- data/lib/rigor/scope.rb +385 -4
- data/lib/rigor/sig_gen/classification.rb +12 -1
- data/lib/rigor/sig_gen/generator.rb +254 -5
- data/lib/rigor/sig_gen/renderer.rb +1 -6
- data/lib/rigor/sig_gen/writer.rb +3 -0
- data/lib/rigor/source/constant_path.rb +79 -2
- data/lib/rigor/triage/catalogue.rb +1 -1
- data/lib/rigor/type/combinator.rb +10 -0
- data/lib/rigor/type/maybe.rb +47 -0
- data/lib/rigor/type/refined.rb +1 -2
- data/lib/rigor/type/result.rb +53 -0
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
- data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
- data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
- data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
- data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
- data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
- data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
- data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
- data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
- data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
- data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
- data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
- data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
- data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
- data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
- data/sig/rigor/analysis/baseline.rbs +1 -1
- data/sig/rigor/environment.rbs +3 -0
- data/sig/rigor/inference.rbs +4 -1
- data/sig/rigor/plugin/io_boundary.rbs +2 -0
- data/sig/rigor/reflection.rbs +3 -1
- data/sig/rigor/scope.rbs +25 -1
- data/sig/rigor/type.rbs +95 -1
- data/sig/rigor.rbs +16 -0
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- metadata +73 -3
data/lib/rigor/configuration.rb
CHANGED
|
@@ -144,10 +144,13 @@ module Rigor
|
|
|
144
144
|
},
|
|
145
145
|
"bundler" => {
|
|
146
146
|
# Open item O4 — target-project Bundler awareness. When `bundle_path:` is set (or auto-detected),
|
|
147
|
-
# Rigor walks `<bundle_path>/ruby/*/gems/*/sig/`
|
|
148
|
-
# `
|
|
149
|
-
#
|
|
150
|
-
#
|
|
147
|
+
# Rigor walks `<bundle_path>/ruby/*/gems/*/sig/` (RubyGems-sourced gems) and
|
|
148
|
+
# `<bundle_path>/ruby/*/bundler/gems/*/sig/` (`git:`-sourced gems) and adds each gem-shipped sig
|
|
149
|
+
# directory to `signature_paths:`. A `path:`-sourced gem is not under either — Bundler uses the
|
|
150
|
+
# `path:` directory in place rather than copying it into the bundle root — so add its `sig/` directly
|
|
151
|
+
# via `signature_paths:` instead. With O7's failure-memo in place, conflicts (a vendored sig already
|
|
152
|
+
# declares the same constant) degrade gracefully to "no RBS env" with a single-line warning naming
|
|
153
|
+
# the offending file, rather than hanging.
|
|
151
154
|
#
|
|
152
155
|
# `bundle_path:` (String, optional): explicit path to the bundler install root (e.g.,
|
|
153
156
|
# "vendor/bundle" or an absolute path). Resolved relative to the project root (`paths:`'s base) when
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "method_key"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Effects
|
|
9
|
+
# Where a method key's `def` is written, resolved from the file the key was already traced to
|
|
10
|
+
# (#435).
|
|
11
|
+
#
|
|
12
|
+
# A drift row names the file out of `Runner#effect_sources`, which rides the cached summary entry
|
|
13
|
+
# and is therefore free. The **line** is not in any value the effects surfaces hold: the discovery
|
|
14
|
+
# tables {EnvelopeCheck::Positions} reads are built by one Prism parse of every project file, which
|
|
15
|
+
# is exactly what [ADR-104](../../../docs/adr/104-effects-boot-slim-probe.md) removed from this
|
|
16
|
+
# command — a warm `rigor effects check` is fast *because* it never parses the project.
|
|
17
|
+
#
|
|
18
|
+
# So this parses the drift's own files and nothing else: an index is built the first time a row asks
|
|
19
|
+
# about a path, and a report with no rows builds none. The cost is proportional to the drift, not to
|
|
20
|
+
# the project — the {EnvelopeCheck::DeferredPositions} shape (#479) applied one layer up, and the
|
|
21
|
+
# reason it can be one layer up is that the caller already knows the file.
|
|
22
|
+
#
|
|
23
|
+
# It is deliberately not a second discovery pass. It answers `def`s and only `def`s: a key whose
|
|
24
|
+
# method has no Ruby `def` at all — a synthesized accessor — keeps the file and loses the line,
|
|
25
|
+
# which is the same degradation `Positions` makes when it falls back to the class's own source.
|
|
26
|
+
class DefinitionLines
|
|
27
|
+
TOPLEVEL = "<toplevel>"
|
|
28
|
+
private_constant :TOPLEVEL
|
|
29
|
+
|
|
30
|
+
NO_LINES = {}.freeze
|
|
31
|
+
private_constant :NO_LINES
|
|
32
|
+
|
|
33
|
+
# @param key [String] an effect unit key — `Tracer::Loud#emit`, `Net::HTTP.get`.
|
|
34
|
+
# @param path [String] the file the key was traced to.
|
|
35
|
+
# @return [Integer, nil] the `def`'s line, or nil when this file does not spell that key with a
|
|
36
|
+
# `def` — an unreadable file, a syntax error, and a synthesized method all land here.
|
|
37
|
+
def for(key:, path:)
|
|
38
|
+
index_for(path)[key]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def index_for(path)
|
|
44
|
+
@indexes ||= {}
|
|
45
|
+
@indexes[path] ||= build_index(path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# First `def` wins: a file that reopens the same method twice has two lines and only one of them is
|
|
49
|
+
# where a reader starts.
|
|
50
|
+
def build_index(path)
|
|
51
|
+
result = Prism.parse_file(path.to_s)
|
|
52
|
+
return NO_LINES unless result.success?
|
|
53
|
+
|
|
54
|
+
{}.tap { |index| walk(result.value, [], singleton: false, index: index) }
|
|
55
|
+
rescue StandardError
|
|
56
|
+
NO_LINES
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The nesting is tracked, never resolved: `class Tracer::Loud` inside `module Tracer` spells a key
|
|
60
|
+
# this cannot see, and answering it would need the constant resolution an engine-free path does not
|
|
61
|
+
# have. A key it cannot spell keeps its file, which is what the row printed before this class existed.
|
|
62
|
+
def walk(node, nesting, singleton:, index:)
|
|
63
|
+
case node
|
|
64
|
+
when Prism::ModuleNode, Prism::ClassNode
|
|
65
|
+
name = constant_name(node.constant_path)
|
|
66
|
+
return if name.nil?
|
|
67
|
+
|
|
68
|
+
walk_children(node.body, nesting + [name], singleton: false, index: index)
|
|
69
|
+
when Prism::SingletonClassNode
|
|
70
|
+
walk_children(node.body, nesting, singleton: true, index: index)
|
|
71
|
+
when Prism::DefNode
|
|
72
|
+
record(node, nesting, singleton: singleton, index: index)
|
|
73
|
+
walk_children(node.body, nesting, singleton: singleton, index: index)
|
|
74
|
+
else
|
|
75
|
+
walk_children(node, nesting, singleton: singleton, index: index)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def walk_children(node, nesting, singleton:, index:)
|
|
80
|
+
node&.compact_child_nodes&.each { |child| walk(child, nesting, singleton: singleton, index: index) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def record(node, nesting, singleton:, index:)
|
|
84
|
+
separator = singleton || node.receiver.is_a?(Prism::SelfNode) ? "." : "#"
|
|
85
|
+
owner = nesting.empty? ? TOPLEVEL : nesting.join("::")
|
|
86
|
+
key = "#{owner}#{separator}#{node.name}"
|
|
87
|
+
index[key] ||= node.location.start_line
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def constant_name(node)
|
|
91
|
+
case node
|
|
92
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
93
|
+
when Prism::ConstantPathNode then node.full_name
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -83,6 +83,21 @@ module Rigor
|
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# {Positions} behind a thunk: the discovery tables — one Prism parse of every project file —
|
|
87
|
+
# are built on the first `.for`, which both judgments reach only once a finding is being
|
|
88
|
+
# constructed. A clean judgment, the common CI case, therefore forces no discovery and parses
|
|
89
|
+
# nothing; that is the whole point of this class existing rather than the pass forcing the
|
|
90
|
+
# tables up front.
|
|
91
|
+
class DeferredPositions
|
|
92
|
+
def initialize(&build)
|
|
93
|
+
@build = build
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def for(key)
|
|
97
|
+
(@positions ||= @build.call).for(key)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
86
101
|
NO_FINDINGS = [].freeze
|
|
87
102
|
private_constant :NO_FINDINGS
|
|
88
103
|
|
|
@@ -93,7 +108,9 @@ module Rigor
|
|
|
93
108
|
# @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
|
|
94
109
|
# @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
|
|
95
110
|
# to the classes they select ({ConfigEnvelopes.for_classes}), to distribute at the lowest precedence.
|
|
96
|
-
# @param positions [Positions] the discovery tables a finding's `def`
|
|
111
|
+
# @param positions [Positions, DeferredPositions] the discovery tables a finding's `def`
|
|
112
|
+
# position is read from — consulted only when a finding is built, so a deferred value's
|
|
113
|
+
# discovery force is reached exactly as often as a finding exists.
|
|
97
114
|
# @param apply_tolerated [Boolean] false judges against the undischarged-by-policy `proven` lane —
|
|
98
115
|
# the `--no-tolerated-effects` audit switch.
|
|
99
116
|
# @return [Array<Finding>] sorted by position then key then label, so a run explains identically twice.
|
|
@@ -112,11 +112,13 @@ module Rigor
|
|
|
112
112
|
|
|
113
113
|
inherited = envelopes.fetch(ancestor_key)
|
|
114
114
|
own = envelopes[key]
|
|
115
|
-
position
|
|
115
|
+
# The position is read inside the two collectors, after they know a finding exists: `.for` is
|
|
116
|
+
# what forces a deferred position table, and an inherited envelope nothing widens must not
|
|
117
|
+
# cost a whole-project discovery parse.
|
|
116
118
|
if own && !own.top?
|
|
117
|
-
collect_declared(findings, key, ancestor_key, inherited, own,
|
|
119
|
+
collect_declared(findings, key, ancestor_key, inherited, own, positions)
|
|
118
120
|
else
|
|
119
|
-
collect_proven(findings, table, key, ancestor_key, inherited,
|
|
121
|
+
collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
|
|
120
122
|
end
|
|
121
123
|
end
|
|
122
124
|
|
|
@@ -135,16 +137,19 @@ module Rigor
|
|
|
135
137
|
nil
|
|
136
138
|
end
|
|
137
139
|
|
|
138
|
-
def collect_proven(findings, table, key, ancestor_key, inherited,
|
|
140
|
+
def collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
|
|
139
141
|
entry = table[key]
|
|
140
142
|
return if entry.nil?
|
|
141
143
|
|
|
142
144
|
exceeding = inherited.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
|
|
145
|
+
return if exceeding.empty?
|
|
146
|
+
|
|
147
|
+
path, line = positions.for(key)
|
|
143
148
|
exceeding.each do |label|
|
|
144
149
|
trail = PathFinder.shortest(table, symbol: key, label: label)
|
|
145
150
|
findings << Finding.new(
|
|
146
151
|
key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
|
|
147
|
-
own_envelope: nil, path:
|
|
152
|
+
own_envelope: nil, path: path, line: line,
|
|
148
153
|
chain: trail&.chain || [key].freeze, origin: trail&.origin
|
|
149
154
|
)
|
|
150
155
|
end
|
|
@@ -152,11 +157,15 @@ module Rigor
|
|
|
152
157
|
|
|
153
158
|
# Two authored bounds, compared by subsumption alone. `mutate.local` is tolerated here as it is
|
|
154
159
|
# everywhere, so declaring it under an inherited `%a{pure}` is not a widening.
|
|
155
|
-
def collect_declared(findings, key, ancestor_key, inherited, own,
|
|
156
|
-
own.bound.to_a.reject { |label| inherited.tolerates?(label) }
|
|
160
|
+
def collect_declared(findings, key, ancestor_key, inherited, own, positions)
|
|
161
|
+
widened = own.bound.to_a.reject { |label| inherited.tolerates?(label) }
|
|
162
|
+
return if widened.empty?
|
|
163
|
+
|
|
164
|
+
path, line = positions.for(key)
|
|
165
|
+
widened.each do |label|
|
|
157
166
|
findings << Finding.new(
|
|
158
167
|
key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
|
|
159
|
-
own_envelope: own, path:
|
|
168
|
+
own_envelope: own, path: path, line: line, chain: nil, origin: nil
|
|
160
169
|
)
|
|
161
170
|
end
|
|
162
171
|
end
|
|
@@ -326,7 +326,7 @@ module Rigor
|
|
|
326
326
|
payload = [
|
|
327
327
|
@labels_by_owner.sort.map { |owner, labels| [owner, labels.sort] },
|
|
328
328
|
@class_rows.keys.sort_by { |singleton| singleton ? 1 : 0 }
|
|
329
|
-
|
|
329
|
+
.map { |singleton| [singleton, sorted(@class_rows[singleton])] },
|
|
330
330
|
sorted(@path_rows), sorted(@self_rows), sorted(@result_rows),
|
|
331
331
|
@edges.map { |edge| [edge.target.to_s, edge.receiver, edge.selector.to_s, edge.plugin_id] }.sort,
|
|
332
332
|
@entry_points.map(&:to_h).sort_by { |preset| preset["name"] },
|
|
@@ -107,10 +107,9 @@ module Rigor
|
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
def walk_namespace(node, prefix)
|
|
110
|
-
|
|
111
|
-
return node.rigor_each_child { |child| walk(child, prefix, false) } if
|
|
110
|
+
nested = Source::ConstantPath.declaration_prefix(prefix, node.constant_path)
|
|
111
|
+
return node.rigor_each_child { |child| walk(child, prefix, false) } if nested.nil?
|
|
112
112
|
|
|
113
|
-
nested = prefix + [name]
|
|
114
113
|
record_superclass(nested.join("::"), node, prefix) if node.is_a?(Prism::ClassNode)
|
|
115
114
|
walk(node.body, nested, false) if node.body
|
|
116
115
|
end
|
|
@@ -21,10 +21,21 @@ module Rigor
|
|
|
21
21
|
DEFAULT_ROOTS = ["sig"].freeze
|
|
22
22
|
|
|
23
23
|
# A cheap text pre-filter for "does this source carry an effect annotation at all". It matches
|
|
24
|
-
# the two
|
|
24
|
+
# the two payloads the envelope reader honours and nothing else, so a signature tree with no
|
|
25
25
|
# effect annotation is answered by one regex per file and never parsed. It is a ROUTING test,
|
|
26
26
|
# not the grammar — `RbsExtended.parse_effect_annotation` is still what decides meaning.
|
|
27
|
-
|
|
27
|
+
#
|
|
28
|
+
# RBS accepts five bracket pairs for an annotation, and the reader sees only the text inside
|
|
29
|
+
# them, so the hint must accept all five too: routing on `%a{` alone made a `%a(pure)` project
|
|
30
|
+
# invisible to the run-cache probe, which then served the fast path while a bound existed —
|
|
31
|
+
# the silent-lane shape the #428 family is about. Over-matching (a mismatched closer) is safe:
|
|
32
|
+
# the cost is one declined fast path or one parsed file, never a missed bound.
|
|
33
|
+
ANNOTATION_BRACKETS = { "{" => "}", "(" => ")", "[" => "]", "|" => "|", "<" => ">" }.freeze
|
|
34
|
+
ANNOTATION_HINT = Regexp.union(
|
|
35
|
+
ANNOTATION_BRACKETS.map do |opener, closer|
|
|
36
|
+
/%a#{Regexp.escape(opener)}\s*(?:pure\s*#{Regexp.escape(closer)}|rigor:v1:effect\b)/
|
|
37
|
+
end
|
|
38
|
+
)
|
|
28
39
|
|
|
29
40
|
# A `virtual:<plugin-id>:<source path>` buffer is rbs-inline's (or a plugin's) synthesized RBS for
|
|
30
41
|
# a Ruby file the author actually wrote in. Naming that file is what a reader can act on, so the
|
|
@@ -38,7 +38,10 @@ module Rigor
|
|
|
38
38
|
class Snapshot
|
|
39
39
|
# Bumped when the file's shape changes in a way an older reader would misread. A bump makes every
|
|
40
40
|
# existing file a regeneration event rather than a silent reinterpretation.
|
|
41
|
-
|
|
41
|
+
# 2 — `unresolved:` became a count (#434). A schema-1 file still loads, and its only reported
|
|
42
|
+
# difference is this field, so an existing project sees one regeneration line rather than a
|
|
43
|
+
# reinterpretation.
|
|
44
|
+
SCHEMA = 2
|
|
42
45
|
|
|
43
46
|
HEADER = "# .rigor-effects.yml — generated by `rigor effects update`. Commit it; review its diff."
|
|
44
47
|
|
|
@@ -47,8 +50,16 @@ module Rigor
|
|
|
47
50
|
|
|
48
51
|
# One row of either table.
|
|
49
52
|
#
|
|
50
|
-
# `effects` and `declared` are sorted label arrays; `unresolved` is
|
|
51
|
-
#
|
|
53
|
+
# `effects` and `declared` are sorted label arrays; `unresolved` is HOW MANY taint causes sit behind
|
|
54
|
+
# a false `exhaustive`, and is zero when the row is exhaustive.
|
|
55
|
+
#
|
|
56
|
+
# A count rather than the causes themselves (#434). On redmine the cause renderings were 144,771 of
|
|
57
|
+
# the file's 293,276 bytes, on lines up to 821 characters — simultaneously the half a reviewer
|
|
58
|
+
# cannot read and the half that churns when an unrelated call moves. This file already makes that
|
|
59
|
+
# argument about origins ("a record keyed by them would churn on every refactor"); the causes are
|
|
60
|
+
# the same class of thing and were the one place it was not applied. What a reviewer needs from the
|
|
61
|
+
# record is the stable fact that the row is not exhaustive and by how much, which is what a count
|
|
62
|
+
# is; `rigor effects explain` names the causes on demand, against the live table.
|
|
52
63
|
class Entry < Data.define(:key, :effects, :declared, :exhaustive, :unresolved)
|
|
53
64
|
def exhaustive?
|
|
54
65
|
exhaustive
|
|
@@ -61,7 +72,7 @@ module Rigor
|
|
|
61
72
|
row = { "effects" => effects }
|
|
62
73
|
row["declared"] = declared unless declared.empty?
|
|
63
74
|
row["exhaustive"] = false unless exhaustive
|
|
64
|
-
row["unresolved"] = unresolved unless unresolved.
|
|
75
|
+
row["unresolved"] = unresolved unless unresolved.zero?
|
|
65
76
|
row
|
|
66
77
|
end
|
|
67
78
|
|
|
@@ -73,10 +84,23 @@ module Rigor
|
|
|
73
84
|
effects: string_list(row["effects"], key, "effects"),
|
|
74
85
|
declared: string_list(row["declared"], key, "declared"),
|
|
75
86
|
exhaustive: row.fetch("exhaustive", true) != false,
|
|
76
|
-
unresolved:
|
|
87
|
+
unresolved: unresolved_count(row["unresolved"], key)
|
|
77
88
|
)
|
|
78
89
|
end
|
|
79
90
|
|
|
91
|
+
# Tolerant of the schema-1 spelling, which was the list itself: the field's whole point now is a
|
|
92
|
+
# stable number, and a schema-1 file must LOAD so the header's own mismatch can be reported as
|
|
93
|
+
# the one regeneration event it is. Refusing to parse it would turn a migration into an error.
|
|
94
|
+
def self.unresolved_count(value, key)
|
|
95
|
+
case value
|
|
96
|
+
when nil then 0
|
|
97
|
+
when Integer then value
|
|
98
|
+
when Array then value.length
|
|
99
|
+
else raise ParseError, "#{key.inspect} unresolved: expected a count, got #{value.class}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
private_class_method :unresolved_count
|
|
103
|
+
|
|
80
104
|
def self.string_list(value, key, field)
|
|
81
105
|
return [].freeze if value.nil?
|
|
82
106
|
raise ParseError, "#{key.inspect} #{field}: expected a list, got #{value.class}" unless value.is_a?(Array)
|
|
@@ -185,6 +209,29 @@ module Rigor
|
|
|
185
209
|
parse(File.read(path, encoding: "UTF-8"))
|
|
186
210
|
end
|
|
187
211
|
|
|
212
|
+
# A path as a reader sees it: project-relative when it is under the root, absolute when it is not.
|
|
213
|
+
#
|
|
214
|
+
# Public for the same reason {.render_causes} is — `reach:`'s entry-point globs and a drift row's
|
|
215
|
+
# source suffix (#435) must agree about what a project-relative path is, and a report that leaked
|
|
216
|
+
# `/private/tmp/nix-shell.../loud.rb` into a reviewer's terminal would be answering with this
|
|
217
|
+
# machine's layout rather than with the project's.
|
|
218
|
+
def relativize(path, project_root)
|
|
219
|
+
absolute = File.absolute_path(path.to_s)
|
|
220
|
+
root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
|
|
221
|
+
absolute.start_with?(root) ? absolute[root.length..] : absolute
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The taint causes as a reviewer reads them, from the closed enum of
|
|
225
|
+
# `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`.
|
|
226
|
+
#
|
|
227
|
+
# Public because two surfaces render the same causes and must not disagree about what one IS:
|
|
228
|
+
# a row's `unresolved:` count is the length of this list, and `rigor effects explain` prints the
|
|
229
|
+
# list itself for an `exhaustive → not` drift row (#434 / #435). One implementation, as WD3 asks.
|
|
230
|
+
def render_causes(causes)
|
|
231
|
+
causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
|
|
232
|
+
.uniq.sort.freeze
|
|
233
|
+
end
|
|
234
|
+
|
|
188
235
|
private
|
|
189
236
|
|
|
190
237
|
def parse_header(data)
|
|
@@ -241,12 +288,6 @@ module Rigor
|
|
|
241
288
|
end
|
|
242
289
|
end
|
|
243
290
|
|
|
244
|
-
def relativize(path, project_root)
|
|
245
|
-
absolute = File.absolute_path(path.to_s)
|
|
246
|
-
root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
|
|
247
|
-
absolute.start_with?(root) ? absolute[root.length..] : absolute
|
|
248
|
-
end
|
|
249
|
-
|
|
250
291
|
# Each table takes the lanes at ITS own reading: `methods:` records the direct summary, so its
|
|
251
292
|
# `declared:` is what this method's own body claims, and `reach:` records the transitive one, so
|
|
252
293
|
# its `declared:` is the fixpoint's — a controller reaching an attributed gem call through two
|
|
@@ -261,19 +302,10 @@ module Rigor
|
|
|
261
302
|
effects: proven.to_a,
|
|
262
303
|
declared: declared.excluding_subsumed_by(proven).to_a,
|
|
263
304
|
exhaustive: exhaustive,
|
|
264
|
-
unresolved: exhaustive ?
|
|
305
|
+
unresolved: exhaustive ? 0 : render_causes(causes).length
|
|
265
306
|
)
|
|
266
307
|
end
|
|
267
308
|
|
|
268
|
-
# `unresolved:` says why the row is not exhaustive, from the closed taint-cause enum of
|
|
269
|
-
# `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`. The
|
|
270
|
-
# design note sketched this field as "call names"; the collector keeps causes rather than names,
|
|
271
|
-
# and for the causes that have one the detail already IS the call name.
|
|
272
|
-
def render_causes(causes)
|
|
273
|
-
causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
|
|
274
|
-
.uniq.sort.freeze
|
|
275
|
-
end
|
|
276
|
-
|
|
277
309
|
# A synthesised default summary — an `attr_*` writer's `mutate.self`, and the `Struct` / `Data`
|
|
278
310
|
# accessors that join it when discovery synthesises them — carries no information a reviewer acts
|
|
279
311
|
# on: it restates the `attr_accessor` line. Recognised by its origins being exactly the synthesised
|
|
@@ -107,6 +107,7 @@ module Rigor
|
|
|
107
107
|
@undischarged = undischarged
|
|
108
108
|
@added_symbols = 0
|
|
109
109
|
@removed_symbols = 0
|
|
110
|
+
@suppressed = 0
|
|
110
111
|
@events = build_events.freeze
|
|
111
112
|
freeze
|
|
112
113
|
end
|
|
@@ -123,9 +124,16 @@ module Rigor
|
|
|
123
124
|
end
|
|
124
125
|
|
|
125
126
|
# Renames are a removal plus an addition and are never reported as a lost effect; the footer is
|
|
126
|
-
# where the reviewer sees that the two counts balance.
|
|
127
|
+
# where the reviewer sees that the two counts balance. `suppressed` is the per-symbol events a
|
|
128
|
+
# regeneration event withheld — zero on an ordinary comparison.
|
|
127
129
|
def footer
|
|
128
|
-
{ added_symbols: @added_symbols, removed_symbols: @removed_symbols }
|
|
130
|
+
{ added_symbols: @added_symbols, removed_symbols: @removed_symbols, suppressed: @suppressed }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Whether the two sides were computed under different rules (#434). The per-symbol comparison is
|
|
134
|
+
# then meaningless rather than merely noisy, which is why {#build_events} withholds it.
|
|
135
|
+
def regeneration?
|
|
136
|
+
@events.any? { |event| event.category == REGENERATION }
|
|
129
137
|
end
|
|
130
138
|
|
|
131
139
|
def events_for(table)
|
|
@@ -145,10 +153,25 @@ module Rigor
|
|
|
145
153
|
ADDITIVE_CATEGORIES.include?(event.category)
|
|
146
154
|
end
|
|
147
155
|
|
|
156
|
+
# A regeneration event withholds the per-symbol comparison rather than printing it (#434).
|
|
157
|
+
#
|
|
158
|
+
# The header says the record was written under different rules, so the two sides are not
|
|
159
|
+
# comparable — a claim this class already makes in its own documentation and then contradicted by
|
|
160
|
+
# emitting every row anyway. On redmine a moved `config_digest:` produced one regeneration line
|
|
161
|
+
# followed by 482 `-symbol` lines, none of which was a review signal: they say the recorded set was
|
|
162
|
+
# computed differently, which the header already said once.
|
|
163
|
+
#
|
|
164
|
+
# The table comparison still RUNS, because its per-symbol counters are what tell the reader the
|
|
165
|
+
# scale of what is withheld. Only the events are dropped.
|
|
148
166
|
def build_events
|
|
149
167
|
return [missing_snapshot_event] if @recorded.nil?
|
|
150
168
|
|
|
151
|
-
|
|
169
|
+
header = header_events
|
|
170
|
+
table = TABLES.flat_map { |name| table_events(name) }
|
|
171
|
+
return table if header.empty?
|
|
172
|
+
|
|
173
|
+
@suppressed = table.length
|
|
174
|
+
header
|
|
152
175
|
end
|
|
153
176
|
|
|
154
177
|
def missing_snapshot_event
|
|
@@ -57,8 +57,12 @@ module Rigor
|
|
|
57
57
|
# When non-nil and non-empty, only `sig/` directories whose gem `(name, version, platform)` tuple
|
|
58
58
|
# matches a lockfile entry are returned. Bundle entries absent from the lockfile (or at a drifted
|
|
59
59
|
# version) are silently dropped — the lockfile is treated as the source of truth for "what gems this
|
|
60
|
-
# project actually declares".
|
|
61
|
-
#
|
|
60
|
+
# project actually declares". A git-sourced directory carries no version to compare (see
|
|
61
|
+
# {.gem_name_from_sig_path}), so for those the filter instead requires the matching `locked_gems`
|
|
62
|
+
# entry to have `git_source: true` — name alone is not enough, since a gem that moved off a `git:`
|
|
63
|
+
# source keeps its stale `bundler/gems/` directory under the SAME name until a `bundle clean`. Pass
|
|
64
|
+
# `nil` (the default) to keep the pre-Layer-3 behaviour of returning every non-skipped `sig/` under
|
|
65
|
+
# the bundle.
|
|
62
66
|
# @return [Array<Pathname>] every `<gem-dir>/sig` directory under the resolved bundle path, minus any
|
|
63
67
|
# whose gem name is in `skip_gems` and (when `locked_gems` is supplied) minus any whose `(name,
|
|
64
68
|
# version, platform)` does not match a lockfile entry.
|
|
@@ -72,14 +76,41 @@ module Rigor
|
|
|
72
76
|
)
|
|
73
77
|
return [] if resolved.nil?
|
|
74
78
|
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
|
|
79
|
+
# Two bundler-install layouts, both under the same `ruby/X.Y.Z/` root (`*` picks up whichever Ruby
|
|
80
|
+
# the bundle was installed for):
|
|
81
|
+
#
|
|
82
|
+
# - `gems/<name>-<ver>/sig/` — the RubyGems-sourced layout, the canonical case.
|
|
83
|
+
# - `bundler/gems/<repo>-<12-hex-revision>/sig/` — `git:`-sourced gems (`Bundler::Source::Git#
|
|
84
|
+
# install_path`); the directory name carries the git repository's basename and a 12-character
|
|
85
|
+
# revision prefix, not the gem name or version. Before this, a fork's own `sig/` was invisible to
|
|
86
|
+
# rigor no matter how it was authored — see issue #611.
|
|
87
|
+
#
|
|
88
|
+
# `path:`-sourced gems are NOT under either glob: Bundler does not copy them into the bundle root at
|
|
89
|
+
# all (`Bundler::Source::Path#path` is the user's own `path:` directory, used in place), so there is
|
|
90
|
+
# no bundle-relative location to walk. A `path:`-sourced gem's `sig/` is only reachable by adding it
|
|
91
|
+
# directly to the project's `signature_paths:` — which already works today and needs no discovery.
|
|
92
|
+
all = (Dir.glob(resolved.join("ruby", "*", "gems", "*", "sig")) +
|
|
93
|
+
Dir.glob(resolved.join("ruby", "*", "bundler", "gems", "*", "sig"))).map { |d| Pathname.new(d) }
|
|
78
94
|
filtered = all.reject { |sig_dir| skip_gems.include?(gem_name_from_sig_path(sig_dir)) }
|
|
79
95
|
return filtered if locked_gems.nil? || locked_gems.empty?
|
|
80
96
|
|
|
81
97
|
expected_dirs = expected_gem_dirs(locked_gems)
|
|
82
|
-
filtered.select
|
|
98
|
+
filtered.select do |sig_dir|
|
|
99
|
+
if git_sourced_layout?(sig_dir)
|
|
100
|
+
# A git install's directory name carries no resolvable version (see above), so the lockfile
|
|
101
|
+
# filter can only check the gem NAME, not `(name, version)` — but name alone is not enough: a
|
|
102
|
+
# gem that used to be `git:`-sourced and has since moved to a released version keeps its old
|
|
103
|
+
# `bundler/gems/<repo>-<sha>/` directory sitting in the bundle tree until a `bundle clean`, and
|
|
104
|
+
# its NAME is still present in the lockfile (now via the rubygems entry). Matching on name only
|
|
105
|
+
# would silently readmit that stale git install — this repo's own `vendor/bundle` had exactly
|
|
106
|
+
# this after `binpacker` moved from `git:` to a released gem. `locked_gems` must say this name is
|
|
107
|
+
# CURRENTLY git-sourced, not merely present.
|
|
108
|
+
locked = locked_gems[gem_name_from_sig_path(sig_dir)]
|
|
109
|
+
!!locked&.git_source
|
|
110
|
+
else
|
|
111
|
+
expected_dirs.include?(sig_dir.parent.basename.to_s)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
83
114
|
end
|
|
84
115
|
|
|
85
116
|
# `{name => LockedGem}` → set of canonical bundler gem directory basenames. Pure-Ruby gems install as
|
|
@@ -98,19 +129,39 @@ module Rigor
|
|
|
98
129
|
end
|
|
99
130
|
private_class_method :expected_gem_dirs
|
|
100
131
|
|
|
101
|
-
# `<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig` → `<name
|
|
102
|
-
# `<
|
|
103
|
-
#
|
|
104
|
-
#
|
|
132
|
+
# `<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig` → `<name>`, or
|
|
133
|
+
# `<bundle>/ruby/X.Y.Z/bundler/gems/<repo>-<12-hex-revision>/sig` → `<repo>`. The two bundler-install
|
|
134
|
+
# layouts name their gem directory differently, so which suffix to strip depends on which layout
|
|
135
|
+
# `sig_dir` came from:
|
|
136
|
+
#
|
|
137
|
+
# - rubygems layout: `<name>-<version>` (platform-tagged variants like `ffi-1.17.4-aarch64-linux-gnu/`
|
|
138
|
+
# keep their platform suffix in the version part) — the version always starts with a digit, so strip
|
|
139
|
+
# from the first `-` followed by a digit.
|
|
140
|
+
# - git layout: `<repo-basename>-<12-hex-char-revision>` (`Bundler::Source::Git#install_path`) — no
|
|
141
|
+
# version at all, and the repository name need not equal the gem name (a fork hosted under a
|
|
142
|
+
# different repo name, a monorepo). Strip the fixed-width hex suffix instead; best-effort only, since
|
|
143
|
+
# the true gem name isn't recoverable from the filesystem without running Bundler.
|
|
105
144
|
#
|
|
106
145
|
# Public so the O4 Layer 3 slice-3 coverage report (`RbsCoverageReport`) can classify discovered bundle
|
|
107
146
|
# sigs against locked gem names without re-running discovery.
|
|
108
147
|
def self.gem_name_from_sig_path(sig_dir)
|
|
109
148
|
gem_dir = sig_dir.parent.basename.to_s
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
149
|
+
if git_sourced_layout?(sig_dir)
|
|
150
|
+
gem_dir.sub(/-[0-9a-f]{12}\z/, "")
|
|
151
|
+
else
|
|
152
|
+
gem_dir.sub(/-\d.*\z/, "")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# True when `sig_dir` sits under the `bundler/gems/` (git-sourced) layout rather than the plain
|
|
157
|
+
# `gems/` (rubygems-sourced) layout — `<bundle>/ruby/X.Y.Z/bundler/gems/<dir>/sig` vs.
|
|
158
|
+
# `<bundle>/ruby/X.Y.Z/gems/<dir>/sig`. A directory-shape check, not a name-shape guess: the two glob
|
|
159
|
+
# roots in {.discover} are already disjoint, so this just recovers which one a given path came from.
|
|
160
|
+
def self.git_sourced_layout?(sig_dir)
|
|
161
|
+
gem_dir = sig_dir.parent
|
|
162
|
+
gem_dir.parent.basename.to_s == "gems" && gem_dir.parent.parent.basename.to_s == "bundler"
|
|
113
163
|
end
|
|
164
|
+
private_class_method :git_sourced_layout?
|
|
114
165
|
|
|
115
166
|
# Returns `Pathname` resolved bundle path, or `nil` when neither explicit nor auto-detected. Public
|
|
116
167
|
# for the stats banner so end users can see what rigor picked up.
|
|
@@ -22,13 +22,19 @@ module Rigor
|
|
|
22
22
|
#
|
|
23
23
|
# `version` is the resolved version string (e.g. "8.0.1"); `platform` is the lockfile's platform tag,
|
|
24
24
|
# normalised to `"ruby"` when the lockfile records `ruby` and to the raw String otherwise (e.g.
|
|
25
|
-
# "aarch64-linux-gnu").
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
# "aarch64-linux-gnu"). `git_source` is true when the lockfile's `GIT` stanza (rather than `GEM`)
|
|
26
|
+
# resolved this gem — see {BundleSigDiscovery}, which needs it to tell a genuine git install apart
|
|
27
|
+
# from a same-named gem the bundle tree has an out-of-band `bundler/gems/` leftover for (e.g. a
|
|
28
|
+
# dependency that moved from a `git:` source to a released version without a `bundle clean`, so its
|
|
29
|
+
# git-installed directory is still sitting there under the OLD source's name). Defaults `false` so
|
|
30
|
+
# existing callers that construct a `LockedGem` without an opinion stay conservative.
|
|
31
|
+
LockedGem = Data.define(:name, :version, :platform, :git_source) do
|
|
32
|
+
def initialize(name:, version:, platform:, git_source: false)
|
|
28
33
|
super(
|
|
29
34
|
name: -name.to_s,
|
|
30
35
|
version: -version.to_s,
|
|
31
|
-
platform: -platform.to_s
|
|
36
|
+
platform: -platform.to_s,
|
|
37
|
+
git_source: git_source ? true : false
|
|
32
38
|
)
|
|
33
39
|
end
|
|
34
40
|
end
|
|
@@ -94,8 +100,12 @@ module Rigor
|
|
|
94
100
|
# (references/rbs/sig/shims/bundler.rbs) does NOT declare `LazySpecification#platform` so the call
|
|
95
101
|
# site needs a suppression marker.
|
|
96
102
|
platform = spec.platform.to_s # rigor:disable undefined-method
|
|
103
|
+
# `#source` is the `Bundler::Source::*` this spec resolved from — `Git` for a `GIT` lockfile
|
|
104
|
+
# stanza, `Rubygems` for a `GEM` one, `Path`/`Gemspec` for `path:`. Only `Git` sources land under
|
|
105
|
+
# {BundleSigDiscovery}'s `bundler/gems/` walk.
|
|
97
106
|
h[spec.name.to_s] = LockedGem.new(
|
|
98
|
-
name: spec.name, version: spec.version.to_s, platform: platform
|
|
107
|
+
name: spec.name, version: spec.version.to_s, platform: platform,
|
|
108
|
+
git_source: spec.source.is_a?(Bundler::Source::Git)
|
|
99
109
|
)
|
|
100
110
|
end
|
|
101
111
|
locked.freeze
|