rigortype 0.1.2 → 0.1.4
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 +135 -31
- data/lib/rigor/analysis/check_rules.rb +10 -18
- data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +113 -0
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
- data/lib/rigor/analysis/dependency_source_inference/index.rb +139 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
- data/lib/rigor/analysis/dependency_source_inference.rb +38 -0
- data/lib/rigor/analysis/diagnostic.rb +0 -2
- data/lib/rigor/analysis/fact_store.rb +11 -3
- data/lib/rigor/analysis/rule_catalog.rb +2 -2
- data/lib/rigor/analysis/runner.rb +206 -6
- data/lib/rigor/builtins/imported_refinements.rb +360 -55
- data/lib/rigor/cache/descriptor.rb +59 -6
- data/lib/rigor/cache/store.rb +1 -1
- data/lib/rigor/cli/diff_command.rb +1 -1
- data/lib/rigor/cli/sig_gen_command.rb +173 -0
- data/lib/rigor/cli/type_of_command.rb +1 -1
- data/lib/rigor/cli/type_scan_renderer.rb +1 -1
- data/lib/rigor/cli/type_scan_report.rb +2 -2
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/configuration/dependencies.rb +235 -0
- data/lib/rigor/configuration.rb +45 -11
- data/lib/rigor/environment.rb +47 -4
- data/lib/rigor/flow_contribution/conflict.rb +2 -2
- data/lib/rigor/flow_contribution/element.rb +1 -1
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/flow_contribution/merge_result.rb +1 -1
- data/lib/rigor/flow_contribution/merger.rb +7 -3
- data/lib/rigor/flow_contribution.rb +2 -2
- data/lib/rigor/inference/block_parameter_binder.rb +0 -2
- data/lib/rigor/inference/coverage_scanner.rb +1 -1
- data/lib/rigor/inference/expression_typer.rb +67 -11
- data/lib/rigor/inference/fallback.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +0 -12
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +118 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +0 -4
- data/lib/rigor/inference/method_dispatcher.rb +233 -2
- data/lib/rigor/inference/method_parameter_binder.rb +1 -3
- data/lib/rigor/inference/narrowing.rb +2 -4
- data/lib/rigor/inference/rbs_type_translator.rb +0 -2
- data/lib/rigor/inference/scope_indexer.rb +14 -9
- data/lib/rigor/inference/statement_evaluator.rb +70 -6
- data/lib/rigor/plugin/io_boundary.rb +0 -2
- data/lib/rigor/plugin/loader.rb +2 -2
- data/lib/rigor/plugin/manifest.rb +49 -7
- data/lib/rigor/plugin/registry.rb +11 -0
- data/lib/rigor/plugin/services.rb +1 -1
- data/lib/rigor/plugin/type_node_resolver.rb +52 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/reporter.rb +91 -0
- data/lib/rigor/rbs_extended.rb +131 -32
- data/lib/rigor/scope.rb +25 -8
- data/lib/rigor/sig_gen/classification.rb +36 -0
- data/lib/rigor/sig_gen/generator.rb +1048 -0
- data/lib/rigor/sig_gen/layout_index.rb +108 -0
- data/lib/rigor/sig_gen/method_candidate.rb +62 -0
- data/lib/rigor/sig_gen/observation_collector.rb +391 -0
- data/lib/rigor/sig_gen/observed_call.rb +62 -0
- data/lib/rigor/sig_gen/path_mapper.rb +116 -0
- data/lib/rigor/sig_gen/renderer.rb +157 -0
- data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
- data/lib/rigor/sig_gen/write_result.rb +48 -0
- data/lib/rigor/sig_gen/writer.rb +530 -0
- data/lib/rigor/sig_gen.rb +25 -0
- data/lib/rigor/type/bound_method.rb +79 -0
- data/lib/rigor/type/combinator.rb +195 -2
- data/lib/rigor/type/constant.rb +13 -0
- data/lib/rigor/type/hash_shape.rb +0 -2
- data/lib/rigor/type/union.rb +20 -1
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/type_node/generic.rb +62 -0
- data/lib/rigor/type_node/identifier.rb +30 -0
- data/lib/rigor/type_node/indexed_access.rb +41 -0
- data/lib/rigor/type_node/integer_literal.rb +29 -0
- data/lib/rigor/type_node/name_scope.rb +52 -0
- data/lib/rigor/type_node/resolver_chain.rb +56 -0
- data/lib/rigor/type_node/string_literal.rb +29 -0
- data/lib/rigor/type_node/symbol_literal.rb +28 -0
- data/lib/rigor/type_node/union.rb +42 -0
- data/lib/rigor/type_node.rb +29 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +2 -0
- data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
- data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
- data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
- data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
- data/sig/rigor/cli/diff_command.rbs +4 -0
- data/sig/rigor/cli/explain_command.rbs +4 -0
- data/sig/rigor/cli/sig_gen_command.rbs +4 -0
- data/sig/rigor/cli/type_scan_command.rbs +3 -0
- data/sig/rigor/environment.rbs +6 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
- data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
- data/sig/rigor/inference/builtins.rbs +2 -0
- data/sig/rigor/plugin/access_denied_error.rbs +3 -0
- data/sig/rigor/plugin/base.rbs +6 -0
- data/sig/rigor/plugin/fact_store.rbs +11 -0
- data/sig/rigor/plugin/io_boundary.rbs +4 -0
- data/sig/rigor/plugin/load_error.rbs +6 -0
- data/sig/rigor/plugin/loader.rbs +20 -0
- data/sig/rigor/plugin/manifest.rbs +9 -0
- data/sig/rigor/plugin/registry.rbs +3 -0
- data/sig/rigor/plugin/services.rbs +3 -0
- data/sig/rigor/plugin/trust_policy.rbs +4 -0
- data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
- data/sig/rigor/plugin.rbs +8 -0
- data/sig/rigor/scope.rbs +4 -2
- data/sig/rigor/type.rbs +28 -6
- metadata +58 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../cache/descriptor"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Analysis
|
|
7
|
+
module DependencySourceInference
|
|
8
|
+
# Per-run collection of gem-source-inference state. Holds
|
|
9
|
+
# the resolved gems the walker MAY visit (slice 2b) plus
|
|
10
|
+
# the unresolvable entries the runner SHOULD surface as
|
|
11
|
+
# `dynamic.dependency-source.gem-not-found` diagnostics.
|
|
12
|
+
#
|
|
13
|
+
# Slice 2a lands the data structure only; the dispatcher
|
|
14
|
+
# tier consults {#contribution_for} but the lookup always
|
|
15
|
+
# answers `nil` until slice 2b populates the method table
|
|
16
|
+
# by walking the resolved gems' `roots:`.
|
|
17
|
+
class Index
|
|
18
|
+
attr_reader :resolved_gems, :unresolvable, :method_catalog, :budget_exceeded,
|
|
19
|
+
:class_to_gem, :budget_overrun_strategy, :gem_modes
|
|
20
|
+
|
|
21
|
+
# @param method_catalog [Hash{[String, Symbol] => Symbol}]
|
|
22
|
+
# the flat `(class_name, method_name) → :instance | :singleton`
|
|
23
|
+
# table produced by {Walker.walk}, aggregated across
|
|
24
|
+
# every resolved gem in the run. The Index itself stays
|
|
25
|
+
# gem-agnostic — the per-gem attribution that slice 3's
|
|
26
|
+
# cache descriptor needs lives on `Resolved`, not here.
|
|
27
|
+
# @param budget_exceeded [Array<String>] gem names whose
|
|
28
|
+
# {Walker} run hit the per-gem catalog cap (slice 4).
|
|
29
|
+
# The Runner consumes this list to emit one
|
|
30
|
+
# `dynamic.dependency-source.budget-exceeded` warning
|
|
31
|
+
# per gem.
|
|
32
|
+
# @param class_to_gem [Hash<String, String>] reverse
|
|
33
|
+
# lookup `class_name → gem_name` (slice 5b). Built
|
|
34
|
+
# first-write-wins: when two opt-in gems re-open the
|
|
35
|
+
# same class, the first gem owns it. The dispatcher
|
|
36
|
+
# consults this map under the `:dependency_silence`
|
|
37
|
+
# budget overrun strategy so call sites on a
|
|
38
|
+
# budget-exceeded gem's classes degrade to
|
|
39
|
+
# `Dynamic[top]` instead of falling through to the
|
|
40
|
+
# user-class fallback.
|
|
41
|
+
# @param gem_modes [Hash<String, Symbol>] per-gem mode
|
|
42
|
+
# table (`gem_name → :disabled | :when_missing |
|
|
43
|
+
# :full`). ADR-10 slice 5c consults this through
|
|
44
|
+
# {#mode_for} to identify call sites where gem-source
|
|
45
|
+
# and RBS both contribute under `mode: :full`. The map
|
|
46
|
+
# is keyed on `gem_name` (not class) because re-opened
|
|
47
|
+
# classes belong to the first gem they appeared in
|
|
48
|
+
# per `class_to_gem`; `mode_for(class_name)` chains
|
|
49
|
+
# the two lookups.
|
|
50
|
+
def initialize(
|
|
51
|
+
resolved_gems: [], unresolvable: [], method_catalog: {},
|
|
52
|
+
budget_exceeded: [], class_to_gem: {},
|
|
53
|
+
budget_overrun_strategy: :walker_cap, gem_modes: {}
|
|
54
|
+
)
|
|
55
|
+
@resolved_gems = resolved_gems.freeze
|
|
56
|
+
@unresolvable = unresolvable.freeze
|
|
57
|
+
@method_catalog = method_catalog.freeze
|
|
58
|
+
@budget_exceeded = budget_exceeded.freeze
|
|
59
|
+
@class_to_gem = class_to_gem.freeze
|
|
60
|
+
@budget_overrun_strategy = budget_overrun_strategy
|
|
61
|
+
@gem_modes = gem_modes.freeze
|
|
62
|
+
freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [String, nil] the gem that owns `class_name`
|
|
66
|
+
# (first-write-wins); `nil` when the class isn't in
|
|
67
|
+
# any opt-in gem's catalog.
|
|
68
|
+
def gem_for(class_name)
|
|
69
|
+
@class_to_gem[class_name]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# ADR-10 slice 5c — per-class mode lookup. Chains
|
|
73
|
+
# `class_to_gem` + `gem_modes`; returns `nil` when the
|
|
74
|
+
# class isn't owned by any opt-in gem in this run.
|
|
75
|
+
def mode_for(class_name)
|
|
76
|
+
gem_name = @class_to_gem[class_name]
|
|
77
|
+
return nil if gem_name.nil?
|
|
78
|
+
|
|
79
|
+
@gem_modes[gem_name]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ADR-10 slice 5c — true when the receiver class belongs
|
|
83
|
+
# to a gem the user opted into `mode: :full` for. The
|
|
84
|
+
# dispatcher consults this AFTER an authoritative-source
|
|
85
|
+
# (RBS / plugin) dispatch resolves so it can record the
|
|
86
|
+
# boundary-crossing for audit.
|
|
87
|
+
def full_mode?(class_name)
|
|
88
|
+
mode_for(class_name) == :full
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Looks up the recorded method kind for a
|
|
92
|
+
# `(class_name, method_name)` pair. Returns `:instance`
|
|
93
|
+
# / `:singleton` when the walker observed a definition
|
|
94
|
+
# under one of the resolved gems' `roots:`, or `nil`
|
|
95
|
+
# otherwise. Slice 2b-ii enriches this with the inferred
|
|
96
|
+
# return type so the dispatcher tier can build a
|
|
97
|
+
# `Type::Dynamic` directly from the lookup result.
|
|
98
|
+
def contribution_for(class_name:, method_name:)
|
|
99
|
+
@method_catalog[[class_name, method_name]]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def empty?
|
|
103
|
+
@resolved_gems.empty?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Builds a frozen `Cache::Descriptor` carrying one
|
|
107
|
+
# `DependencyEntry` row per resolved gem in this run.
|
|
108
|
+
# Cache producers that observe ADR-10 inference outputs
|
|
109
|
+
# compose this descriptor with their own (RBS, plugin,
|
|
110
|
+
# file-digest) descriptors so a `bundle update` on a
|
|
111
|
+
# listed gem invalidates exactly that gem's slice while
|
|
112
|
+
# leaving the rest of the cache hot.
|
|
113
|
+
#
|
|
114
|
+
# Unresolvable entries contribute nothing — there is no
|
|
115
|
+
# version to key on, and the runner already surfaces them
|
|
116
|
+
# as `dynamic.dependency-source.gem-not-found`
|
|
117
|
+
# diagnostics. Resolved-but-disabled entries are also
|
|
118
|
+
# absent: the {Builder} skips them before resolution, so
|
|
119
|
+
# they never reach the index.
|
|
120
|
+
def cache_descriptor
|
|
121
|
+
dependencies = @resolved_gems.map do |resolved|
|
|
122
|
+
Cache::Descriptor::DependencyEntry.new(
|
|
123
|
+
gem_name: resolved.gem_name,
|
|
124
|
+
gem_version: resolved.version,
|
|
125
|
+
mode: resolved.mode
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
Cache::Descriptor.new(dependencies: dependencies)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Frozen empty index — the runner uses this when
|
|
133
|
+
# `Configuration#dependencies.source_inference` is empty
|
|
134
|
+
# so the dispatcher tier holds a stable, non-nil
|
|
135
|
+
# reference even on default configurations.
|
|
136
|
+
Index::EMPTY = Index.new.freeze
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Analysis
|
|
7
|
+
module DependencySourceInference
|
|
8
|
+
# Walks a resolved gem's `roots:` and collects the
|
|
9
|
+
# `(class_name, method_name) → :instance | :singleton`
|
|
10
|
+
# method catalog. The walker is the source of facts the
|
|
11
|
+
# dispatcher tier (slice 2b-ii) consults to recognise a
|
|
12
|
+
# method as defined by an opt-in gem and contribute a
|
|
13
|
+
# `Type::Dynamic` return at the call site.
|
|
14
|
+
#
|
|
15
|
+
# Slice 2b-i intentionally collects only the catalog, not
|
|
16
|
+
# the inferred return type. The dispatcher tier returns
|
|
17
|
+
# `Dynamic[top]` on a hit until slice 2b-ii wires return-
|
|
18
|
+
# type inference; the visible payoff today is removing the
|
|
19
|
+
# `call.undefined-method` diagnostic for opt-in gem methods
|
|
20
|
+
# at receivers Rigor knows by `Nominal[T]` (typically
|
|
21
|
+
# because the user authored an RBS skeleton).
|
|
22
|
+
#
|
|
23
|
+
# Hard exclusions are NOT user-configurable, per ADR-10
|
|
24
|
+
# § "Hard exclusions": top-level `spec/`, `test/`, `bin/`,
|
|
25
|
+
# plus any non-`.rb` source. C extensions fall out
|
|
26
|
+
# automatically because the walker only loads `.rb` files.
|
|
27
|
+
module Walker
|
|
28
|
+
# Top-level directories that MUST NOT participate in
|
|
29
|
+
# gem-source inference even when the user lists them
|
|
30
|
+
# under `roots:`. The check is case-insensitive against
|
|
31
|
+
# the first segment of `roots:`; nested `spec/` /
|
|
32
|
+
# `test/` directories deeper inside `lib/` are NOT
|
|
33
|
+
# filtered (a few gems legitimately ship `lib/.../spec/`).
|
|
34
|
+
HARD_EXCLUDED_ROOTS = %w[spec test bin].freeze
|
|
35
|
+
|
|
36
|
+
# Walker outcome wrapping the harvested method catalog
|
|
37
|
+
# plus a budget-exceeded flag. ADR-10 slice 4 introduces
|
|
38
|
+
# the cap; the Walker stops appending to the accumulator
|
|
39
|
+
# once `catalog.size` reaches `budget`, and `truncated?`
|
|
40
|
+
# reports whether the cap was reached. The Index records
|
|
41
|
+
# this per-gem so the Runner can surface a single
|
|
42
|
+
# `dynamic.dependency-source.budget-exceeded` warning
|
|
43
|
+
# naming the affected gem(s).
|
|
44
|
+
class Outcome < Data.define(:catalog, :truncated)
|
|
45
|
+
def truncated? = truncated
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Sentinel for "no cap" — used by callers that don't
|
|
49
|
+
# care about the budget (specs, tooling). Production
|
|
50
|
+
# code MUST pass an integer.
|
|
51
|
+
UNBOUNDED = Float::INFINITY
|
|
52
|
+
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
# @param gem_dir [String, Pathname] absolute path to the
|
|
56
|
+
# gem's installation directory.
|
|
57
|
+
# @param roots [Array<String>] subdirectory names within
|
|
58
|
+
# the gem to walk (defaults to `["lib"]` per
|
|
59
|
+
# `Configuration::Dependencies::Entry`).
|
|
60
|
+
# @param budget [Integer, Float] per-gem catalog cap
|
|
61
|
+
# (method-definition count). When unset, defaults to
|
|
62
|
+
# `UNBOUNDED` for backwards-compatible test paths.
|
|
63
|
+
# @return [Outcome] frozen wrapper carrying the catalog
|
|
64
|
+
# (`Hash{[class_name, method_name] => :instance |
|
|
65
|
+
# :singleton}`) and a `truncated?` flag set when the
|
|
66
|
+
# walker stopped harvesting because the budget was
|
|
67
|
+
# reached. Methods of identical name on the same class
|
|
68
|
+
# with different kinds (rare; private API mostly)
|
|
69
|
+
# carry the kind that wins the per-class first walk.
|
|
70
|
+
def walk(gem_dir:, roots:, budget: UNBOUNDED)
|
|
71
|
+
accumulator = {}
|
|
72
|
+
truncated = false
|
|
73
|
+
accepted_roots(roots).each do |root|
|
|
74
|
+
break if truncated
|
|
75
|
+
|
|
76
|
+
truncated = walk_root(File.join(gem_dir.to_s, root), accumulator, budget)
|
|
77
|
+
end
|
|
78
|
+
Outcome.new(catalog: accumulator.freeze, truncated: truncated)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Drops hard-excluded entries before any filesystem
|
|
82
|
+
# walk happens. Reasoning: we never want a gem's
|
|
83
|
+
# `spec/` to participate even if the user requested
|
|
84
|
+
# it — the noise from RSpec-style globals plus the
|
|
85
|
+
# cost of walking test fixtures isn't worth the
|
|
86
|
+
# marginal coverage.
|
|
87
|
+
def accepted_roots(roots)
|
|
88
|
+
roots.reject { |root| HARD_EXCLUDED_ROOTS.include?(root.downcase) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Returns true when the budget tripped during this
|
|
92
|
+
# root's walk so the caller can stop iterating
|
|
93
|
+
# subsequent roots.
|
|
94
|
+
def walk_root(root_dir, accumulator, budget) # rubocop:disable Naming/PredicateMethod
|
|
95
|
+
return false unless File.directory?(root_dir)
|
|
96
|
+
|
|
97
|
+
Dir.glob(File.join(root_dir, "**", "*.rb")).each do |path|
|
|
98
|
+
harvest_file(path, accumulator, budget)
|
|
99
|
+
return true if accumulator.size >= budget
|
|
100
|
+
end
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def harvest_file(path, accumulator, budget)
|
|
105
|
+
parse_result = Prism.parse_file(path)
|
|
106
|
+
return unless parse_result.errors.empty?
|
|
107
|
+
|
|
108
|
+
walk_node(parse_result.value, [], false, accumulator, budget)
|
|
109
|
+
rescue StandardError
|
|
110
|
+
# Gem source we can't parse / read silently degrades
|
|
111
|
+
# to "no contribution from this file". The user-facing
|
|
112
|
+
# diagnostic stream is reserved for the project source;
|
|
113
|
+
# opt-in gem source MUST NOT pollute it with parse
|
|
114
|
+
# errors the user cannot fix.
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Walks a Prism subtree, accumulating method definitions
|
|
119
|
+
# under their qualified class name. Mirrors the shape of
|
|
120
|
+
# `Inference::ScopeIndexer#walk_methods` but stays
|
|
121
|
+
# decoupled from `Scope` because gem-source inference
|
|
122
|
+
# runs without a scope context.
|
|
123
|
+
def walk_node(node, qualified_prefix, in_singleton_class, accumulator, budget)
|
|
124
|
+
return unless node.is_a?(Prism::Node)
|
|
125
|
+
return if accumulator.size >= budget
|
|
126
|
+
|
|
127
|
+
case node
|
|
128
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
129
|
+
descend_class_or_module(node, qualified_prefix, in_singleton_class, accumulator, budget)
|
|
130
|
+
when Prism::SingletonClassNode
|
|
131
|
+
descend_singleton_class(node, qualified_prefix, accumulator, budget)
|
|
132
|
+
when Prism::DefNode
|
|
133
|
+
record_def_node(node, qualified_prefix, in_singleton_class, accumulator, budget)
|
|
134
|
+
else
|
|
135
|
+
walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
|
|
140
|
+
node.compact_child_nodes.each do |child|
|
|
141
|
+
break if accumulator.size >= budget
|
|
142
|
+
|
|
143
|
+
walk_node(child, qualified_prefix, in_singleton_class, accumulator, budget)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# `class Foo` / `module Bar`. The dynamic-prefix shape
|
|
148
|
+
# (`module ::Foo`-rooted variants whose left side is a
|
|
149
|
+
# runtime expression) is treated as opaque — we walk the
|
|
150
|
+
# children under the same prefix so any inner class
|
|
151
|
+
# definitions are still recorded under their own name.
|
|
152
|
+
def descend_class_or_module(node, qualified_prefix, in_singleton_class, accumulator, budget)
|
|
153
|
+
name = qualified_name_for(node.constant_path)
|
|
154
|
+
if name && node.body
|
|
155
|
+
walk_node(node.body, qualified_prefix + [name], in_singleton_class, accumulator, budget)
|
|
156
|
+
else
|
|
157
|
+
walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# `class << self` only — `class << expr` for any other
|
|
162
|
+
# `expr` is treated as opaque so we don't accidentally
|
|
163
|
+
# record per-instance singleton methods under the
|
|
164
|
+
# surrounding class.
|
|
165
|
+
def descend_singleton_class(node, qualified_prefix, accumulator, budget)
|
|
166
|
+
if node.expression.is_a?(Prism::SelfNode) && node.body
|
|
167
|
+
walk_node(node.body, qualified_prefix, true, accumulator, budget)
|
|
168
|
+
else
|
|
169
|
+
walk_children(node, qualified_prefix, false, accumulator, budget)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def record_def_node(node, qualified_prefix, in_singleton_class, accumulator, _budget)
|
|
174
|
+
return if qualified_prefix.empty?
|
|
175
|
+
|
|
176
|
+
class_name = qualified_prefix.join("::")
|
|
177
|
+
kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
|
|
178
|
+
accumulator[[class_name, node.name]] ||= kind
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Resolves a `Prism::ConstantPathNode` /
|
|
182
|
+
# `Prism::ConstantReadNode` chain to its dot-separated
|
|
183
|
+
# name (e.g. `"Foo::Bar"`). Returns nil for the rare
|
|
184
|
+
# dynamic-prefix shape (`module ::Foo`-rooted variants
|
|
185
|
+
# whose left side is a runtime expression) so the
|
|
186
|
+
# walker treats those as opaque rather than guessing.
|
|
187
|
+
def qualified_name_for(node)
|
|
188
|
+
case node
|
|
189
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
190
|
+
when Prism::ConstantPathNode
|
|
191
|
+
parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
|
|
192
|
+
return nil if !node.parent.nil? && parent.nil?
|
|
193
|
+
|
|
194
|
+
parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dependency_source_inference/boundary_cross_reporter"
|
|
4
|
+
require_relative "dependency_source_inference/gem_resolver"
|
|
5
|
+
require_relative "dependency_source_inference/index"
|
|
6
|
+
require_relative "dependency_source_inference/walker"
|
|
7
|
+
require_relative "dependency_source_inference/builder"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Analysis
|
|
11
|
+
# Implementation of [ADR-10 — Opt-in dependency-source
|
|
12
|
+
# inference](../../../docs/adr/10-dependency-source-inference.md).
|
|
13
|
+
#
|
|
14
|
+
# The namespace coordinates three components:
|
|
15
|
+
#
|
|
16
|
+
# - {GemResolver} maps a
|
|
17
|
+
# `Configuration::Dependencies::Entry` to either a frozen
|
|
18
|
+
# `Resolved(gem_name, version, gem_dir, mode, roots)` or an
|
|
19
|
+
# `Unresolvable(gem_name, reason)` value.
|
|
20
|
+
# - {Builder.build} folds a `Configuration::Dependencies`
|
|
21
|
+
# into a frozen {Index} carrying the partitioned outcomes.
|
|
22
|
+
# - {Index} holds the per-run state the dispatcher tier
|
|
23
|
+
# consults via `#contribution_for`. Slice 2a ships the
|
|
24
|
+
# stub returning `nil`; slice 2b populates the method
|
|
25
|
+
# table by walking each resolved gem's `roots:`.
|
|
26
|
+
#
|
|
27
|
+
# Per the ADR's "Implementation slicing" section, slice 2 is
|
|
28
|
+
# split internally:
|
|
29
|
+
#
|
|
30
|
+
# - Slice 2a (this commit): gem resolution, index plumbing,
|
|
31
|
+
# `Analysis::Runner` wiring, `dynamic.dependency-source.gem-not-found`
|
|
32
|
+
# diagnostic for unresolvable entries.
|
|
33
|
+
# - Slice 2b (next commit): walker, dispatcher tier
|
|
34
|
+
# integration, `Type::Dynamic`-wrapped returns.
|
|
35
|
+
module DependencySourceInference
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -24,10 +24,8 @@ module Rigor
|
|
|
24
24
|
# ADR-2 § "Plugin Diagnostic Provenance") let consumers
|
|
25
25
|
# distinguish where a diagnostic originated without committing
|
|
26
26
|
# to the plugin API itself.
|
|
27
|
-
# rubocop:disable Metrics/ParameterLists
|
|
28
27
|
def initialize(path:, line:, column:, message:, severity: :error, rule: nil,
|
|
29
28
|
source_family: DEFAULT_SOURCE_FAMILY)
|
|
30
|
-
# rubocop:enable Metrics/ParameterLists
|
|
31
29
|
@path = path
|
|
32
30
|
@line = line
|
|
33
31
|
@column = column
|
|
@@ -18,7 +18,7 @@ module Rigor
|
|
|
18
18
|
relational
|
|
19
19
|
].freeze
|
|
20
20
|
|
|
21
|
-
Target
|
|
21
|
+
class Target < Data.define(:kind, :name)
|
|
22
22
|
def self.local(name)
|
|
23
23
|
new(kind: :local, name: name.to_sym)
|
|
24
24
|
end
|
|
@@ -28,7 +28,7 @@ module Rigor
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
Fact
|
|
31
|
+
class Fact < Data.define(:bucket, :target, :predicate, :payload, :polarity, :stability)
|
|
32
32
|
def initialize(bucket:, target:, predicate:, payload: nil, polarity: :positive, stability: :local_binding)
|
|
33
33
|
bucket = bucket.to_sym
|
|
34
34
|
raise ArgumentError, "unknown fact bucket #{bucket.inspect}" unless BUCKETS.include?(bucket)
|
|
@@ -125,8 +125,16 @@ module Rigor
|
|
|
125
125
|
unique.freeze
|
|
126
126
|
end
|
|
127
127
|
|
|
128
|
+
# `fact.target` is `Target | Array[Target]` per the carrier
|
|
129
|
+
# contract. Branching with an early return on the `Array`
|
|
130
|
+
# arm lets type narrowing collapse the post-return value to
|
|
131
|
+
# the bare `Target` case, so the wrapped tuple is `[Target]`
|
|
132
|
+
# and the union of return paths is exactly `Array[Target]`.
|
|
128
133
|
def fact_targets(fact)
|
|
129
|
-
|
|
134
|
+
target = fact.target
|
|
135
|
+
return target if target.is_a?(Array)
|
|
136
|
+
|
|
137
|
+
[target]
|
|
130
138
|
end
|
|
131
139
|
end
|
|
132
140
|
end
|
|
@@ -31,8 +31,8 @@ module Rigor
|
|
|
31
31
|
# from `Configuration::SeverityProfile::PROFILES`.
|
|
32
32
|
# - `since` — first version the rule shipped in.
|
|
33
33
|
module RuleCatalog # rubocop:disable Metrics/ModuleLength
|
|
34
|
-
Entry
|
|
35
|
-
|
|
34
|
+
class Entry < Data.define(:id, :summary, :fires_when, :does_not_fire_when,
|
|
35
|
+
:suppression, :severity_authored, :severity_by_profile, :since)
|
|
36
36
|
def aliases
|
|
37
37
|
CheckRules::LEGACY_RULE_ALIASES.select { |_legacy, canonical| canonical == id }.keys
|
|
38
38
|
end
|