rigortype 0.1.2 → 0.1.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 +10 -0
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +87 -0
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
- data/lib/rigor/analysis/dependency_source_inference/index.rb +110 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
- data/lib/rigor/analysis/dependency_source_inference.rb +37 -0
- data/lib/rigor/analysis/runner.rb +94 -5
- data/lib/rigor/cache/descriptor.rb +58 -5
- data/lib/rigor/configuration/dependencies.rb +235 -0
- data/lib/rigor/configuration.rb +43 -9
- data/lib/rigor/environment.rb +16 -4
- data/lib/rigor/flow_contribution/merger.rb +4 -0
- data/lib/rigor/inference/method_dispatcher.rb +87 -0
- data/lib/rigor/inference/statement_evaluator.rb +65 -1
- data/lib/rigor/plugin/manifest.rb +26 -5
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +3 -2
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3542aa2842a06783d02f8f35b7f31247eb8d10dce91214b31364839ad7087809
|
|
4
|
+
data.tar.gz: 6beca5ec330dab3264cb64a110bd4e6a790e16a92c5f1cf10383799b988d2bb9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33d98371534cd4d193b39afe08dc5b442cd90e0909388a9e9a20413d8ae766665d8bb7a8573bb2236997a72ce178e35c7d2c62c64ceac804f1a31bf88b079950
|
|
7
|
+
data.tar.gz: 0fca38a07730117fa58b42d8c14642e86540a492ce0513c4612dbf58049600bb8adc224fc32cdfc518ba581a870c478b8dfbbc02ce43bf78eb251c3d1dd771db
|
data/README.md
CHANGED
|
@@ -239,6 +239,16 @@ Rigor consults, in order:
|
|
|
239
239
|
Rigor walks `def` / `define_method` / `attr_*` /
|
|
240
240
|
`Data.define(*Symbol)` so user-defined methods on a class
|
|
241
241
|
are recognised.
|
|
242
|
+
5. **Opt-in gem-source inference (ADR-10).** Gems listed
|
|
243
|
+
under `dependencies.source_inference:` in `.rigor.yml`
|
|
244
|
+
have their `lib/` walked the same way project source is,
|
|
245
|
+
so methods on those gems' classes resolve even without
|
|
246
|
+
RBS. Inferred returns crossing the gem boundary are
|
|
247
|
+
wrapped in `Dynamic[T]` so the call site retains the
|
|
248
|
+
provenance — RBS / RBS::Inline / generated stubs / plugin
|
|
249
|
+
contracts always win on conflict. Default behaviour is
|
|
250
|
+
unchanged: gems not listed stay at the
|
|
251
|
+
RBS-or-`Dynamic[Top]` boundary.
|
|
242
252
|
|
|
243
253
|
If a type cannot be proved, the engine returns `Dynamic[Top]`
|
|
244
254
|
(Rigor's gradual carrier) and stays silent — Rigor never invents
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "gem_resolver"
|
|
4
|
+
require_relative "index"
|
|
5
|
+
require_relative "walker"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Analysis
|
|
9
|
+
module DependencySourceInference
|
|
10
|
+
# Folds a `Configuration::Dependencies` value into a
|
|
11
|
+
# frozen {Index}. Resolves each non-disabled entry through
|
|
12
|
+
# {GemResolver}, walks each resolved gem's `roots:` via
|
|
13
|
+
# {Walker.walk} under the configured `budget_per_gem` cap,
|
|
14
|
+
# and aggregates the per-gem method catalogs into the
|
|
15
|
+
# Index's flat `(class_name, method_name) → kind` table.
|
|
16
|
+
#
|
|
17
|
+
# Entries with `mode: :disabled` are skipped without
|
|
18
|
+
# resolution attempts so users can "list and disable" a
|
|
19
|
+
# gem in configuration without provoking a missing-gem
|
|
20
|
+
# diagnostic.
|
|
21
|
+
module Builder
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# @param dependencies [Rigor::Configuration::Dependencies]
|
|
25
|
+
# @return [Index]
|
|
26
|
+
def build(dependencies) # rubocop:disable Metrics/MethodLength
|
|
27
|
+
return Index::EMPTY if dependencies.empty?
|
|
28
|
+
|
|
29
|
+
resolved = []
|
|
30
|
+
unresolvable = []
|
|
31
|
+
catalog = {}
|
|
32
|
+
class_to_gem = {}
|
|
33
|
+
budget_exceeded = []
|
|
34
|
+
budget = dependencies.budget_per_gem
|
|
35
|
+
|
|
36
|
+
dependencies.source_inference.each do |entry|
|
|
37
|
+
next if entry.disabled?
|
|
38
|
+
|
|
39
|
+
outcome = GemResolver.resolve(entry)
|
|
40
|
+
case outcome
|
|
41
|
+
when GemResolver::Resolved
|
|
42
|
+
resolved << outcome
|
|
43
|
+
walked = walker_outcome_for(outcome, budget)
|
|
44
|
+
catalog.merge!(walked.catalog)
|
|
45
|
+
record_class_to_gem(walked.catalog, outcome.gem_name, class_to_gem)
|
|
46
|
+
budget_exceeded << outcome.gem_name if walked.truncated?
|
|
47
|
+
when GemResolver::Unresolvable then unresolvable << outcome
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Index.new(
|
|
52
|
+
resolved_gems: resolved, unresolvable: unresolvable,
|
|
53
|
+
method_catalog: catalog, budget_exceeded: budget_exceeded,
|
|
54
|
+
class_to_gem: class_to_gem,
|
|
55
|
+
budget_overrun_strategy: dependencies.budget_overrun_strategy
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ADR-10 5b — per-class reverse-lookup table (β budget
|
|
60
|
+
# semantics). Records `class_name → gem_name` for every
|
|
61
|
+
# class observed in the gem's catalog. First-write-wins:
|
|
62
|
+
# if two opt-in gems re-open the same class, the first
|
|
63
|
+
# gem to harvest the class owns it in the reverse index.
|
|
64
|
+
# The dispatcher only consults this map when the
|
|
65
|
+
# `budget_overrun_strategy` is `:dependency_silence`,
|
|
66
|
+
# so the storage cost is never paid back unless the
|
|
67
|
+
# user opts in.
|
|
68
|
+
def record_class_to_gem(catalog, gem_name, class_to_gem)
|
|
69
|
+
catalog.each_key do |(class_name, _method_name)|
|
|
70
|
+
class_to_gem[class_name] ||= gem_name
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Per-resolved-gem walk. Isolated so a single gem's
|
|
75
|
+
# filesystem error / parse failure cannot abort the
|
|
76
|
+
# build; the walker swallows its own per-file errors,
|
|
77
|
+
# and a top-level raise here degrades the gem to "no
|
|
78
|
+
# contributions" without touching the rest of the run.
|
|
79
|
+
def walker_outcome_for(resolved, budget)
|
|
80
|
+
Walker.walk(gem_dir: resolved.gem_dir, roots: resolved.roots, budget: budget)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
Walker::Outcome.new(catalog: {}.freeze, truncated: false)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
module DependencySourceInference
|
|
6
|
+
# Maps a `Configuration::Dependencies::Entry` to the gem's
|
|
7
|
+
# on-disk installation directory by consulting RubyGems
|
|
8
|
+
# (`Gem.loaded_specs` first, falling back to
|
|
9
|
+
# `Gem::Specification.find_by_name`). Returns either a
|
|
10
|
+
# frozen {Resolved} value object or an {Unresolvable} value
|
|
11
|
+
# describing why the gem cannot participate in this run.
|
|
12
|
+
#
|
|
13
|
+
# Resolution failures are surfaced as
|
|
14
|
+
# `dynamic.dependency-source.gem-not-found` diagnostics by
|
|
15
|
+
# {Analysis::Runner} rather than crashing the run, so a
|
|
16
|
+
# missing gem in `dependencies.source_inference` degrades
|
|
17
|
+
# cleanly to "no contributions from that gem" — every other
|
|
18
|
+
# gem and the project source remain unaffected.
|
|
19
|
+
module GemResolver
|
|
20
|
+
# Successful resolution. `version` is the spec version as
|
|
21
|
+
# a String so it round-trips into cache descriptors
|
|
22
|
+
# (slice 3) without leaking a `Gem::Version` instance
|
|
23
|
+
# through public surfaces.
|
|
24
|
+
Resolved = Data.define(:gem_name, :version, :gem_dir, :mode, :roots) do
|
|
25
|
+
def descriptor_key
|
|
26
|
+
[gem_name, version, mode].freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Unresolvable reasons. `:not_in_bundle` covers both the
|
|
31
|
+
# "RubyGems doesn't know this gem" case and the
|
|
32
|
+
# `LoadError`-style raise from `find_by_name`. Future
|
|
33
|
+
# reasons (`:c_extension_only`, `:no_lib_root`) are
|
|
34
|
+
# introduced as the walker discovers them in slice 2b.
|
|
35
|
+
Unresolvable = Data.define(:gem_name, :reason)
|
|
36
|
+
|
|
37
|
+
VALID_REASONS = %i[not_in_bundle].freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# @param entry [Rigor::Configuration::Dependencies::Entry]
|
|
42
|
+
# @return [Resolved, Unresolvable]
|
|
43
|
+
def resolve(entry)
|
|
44
|
+
spec = locate_gem_spec(entry.gem)
|
|
45
|
+
return Unresolvable.new(gem_name: entry.gem, reason: :not_in_bundle) if spec.nil?
|
|
46
|
+
|
|
47
|
+
Resolved.new(
|
|
48
|
+
gem_name: entry.gem,
|
|
49
|
+
version: spec.version.to_s,
|
|
50
|
+
gem_dir: spec.full_gem_path, # rigor:disable undefined-method
|
|
51
|
+
mode: entry.mode,
|
|
52
|
+
roots: entry.roots
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Locator. `Gem.loaded_specs` reflects the bundle (cheap
|
|
57
|
+
# lookup, no filesystem walk); `find_by_name` is the
|
|
58
|
+
# broader fallback for gems present on the gem path but
|
|
59
|
+
# not yet `require`'d. `Gem::MissingSpecError` is a
|
|
60
|
+
# `LoadError` subclass, so the rescue covers both
|
|
61
|
+
# missing-spec and load-error signals.
|
|
62
|
+
def locate_gem_spec(name)
|
|
63
|
+
Gem.loaded_specs[name] || begin
|
|
64
|
+
Gem::Specification.find_by_name(name)
|
|
65
|
+
rescue LoadError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
|
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
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
42
|
+
resolved_gems: [], unresolvable: [], method_catalog: {},
|
|
43
|
+
budget_exceeded: [], class_to_gem: {},
|
|
44
|
+
budget_overrun_strategy: :walker_cap
|
|
45
|
+
)
|
|
46
|
+
@resolved_gems = resolved_gems.freeze
|
|
47
|
+
@unresolvable = unresolvable.freeze
|
|
48
|
+
@method_catalog = method_catalog.freeze
|
|
49
|
+
@budget_exceeded = budget_exceeded.freeze
|
|
50
|
+
@class_to_gem = class_to_gem.freeze
|
|
51
|
+
@budget_overrun_strategy = budget_overrun_strategy
|
|
52
|
+
freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [String, nil] the gem that owns `class_name`
|
|
56
|
+
# (first-write-wins); `nil` when the class isn't in
|
|
57
|
+
# any opt-in gem's catalog.
|
|
58
|
+
def gem_for(class_name)
|
|
59
|
+
@class_to_gem[class_name]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Looks up the recorded method kind for a
|
|
63
|
+
# `(class_name, method_name)` pair. Returns `:instance`
|
|
64
|
+
# / `:singleton` when the walker observed a definition
|
|
65
|
+
# under one of the resolved gems' `roots:`, or `nil`
|
|
66
|
+
# otherwise. Slice 2b-ii enriches this with the inferred
|
|
67
|
+
# return type so the dispatcher tier can build a
|
|
68
|
+
# `Type::Dynamic` directly from the lookup result.
|
|
69
|
+
def contribution_for(class_name:, method_name:)
|
|
70
|
+
@method_catalog[[class_name, method_name]]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def empty?
|
|
74
|
+
@resolved_gems.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Builds a frozen `Cache::Descriptor` carrying one
|
|
78
|
+
# `DependencyEntry` row per resolved gem in this run.
|
|
79
|
+
# Cache producers that observe ADR-10 inference outputs
|
|
80
|
+
# compose this descriptor with their own (RBS, plugin,
|
|
81
|
+
# file-digest) descriptors so a `bundle update` on a
|
|
82
|
+
# listed gem invalidates exactly that gem's slice while
|
|
83
|
+
# leaving the rest of the cache hot.
|
|
84
|
+
#
|
|
85
|
+
# Unresolvable entries contribute nothing — there is no
|
|
86
|
+
# version to key on, and the runner already surfaces them
|
|
87
|
+
# as `dynamic.dependency-source.gem-not-found`
|
|
88
|
+
# diagnostics. Resolved-but-disabled entries are also
|
|
89
|
+
# absent: the {Builder} skips them before resolution, so
|
|
90
|
+
# they never reach the index.
|
|
91
|
+
def cache_descriptor
|
|
92
|
+
dependencies = @resolved_gems.map do |resolved|
|
|
93
|
+
Cache::Descriptor::DependencyEntry.new(
|
|
94
|
+
gem_name: resolved.gem_name,
|
|
95
|
+
gem_version: resolved.version,
|
|
96
|
+
mode: resolved.mode
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
Cache::Descriptor.new(dependencies: dependencies)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Frozen empty index — the runner uses this when
|
|
104
|
+
# `Configuration#dependencies.source_inference` is empty
|
|
105
|
+
# so the dispatcher tier holds a stable, non-nil
|
|
106
|
+
# reference even on default configurations.
|
|
107
|
+
Index::EMPTY = Index.new.freeze
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
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
|
+
Outcome = Data.define(:catalog, :truncated) do
|
|
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,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dependency_source_inference/gem_resolver"
|
|
4
|
+
require_relative "dependency_source_inference/index"
|
|
5
|
+
require_relative "dependency_source_inference/walker"
|
|
6
|
+
require_relative "dependency_source_inference/builder"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Analysis
|
|
10
|
+
# Implementation of [ADR-10 — Opt-in dependency-source
|
|
11
|
+
# inference](../../../docs/adr/10-dependency-source-inference.md).
|
|
12
|
+
#
|
|
13
|
+
# The namespace coordinates three components:
|
|
14
|
+
#
|
|
15
|
+
# - {GemResolver} maps a
|
|
16
|
+
# `Configuration::Dependencies::Entry` to either a frozen
|
|
17
|
+
# `Resolved(gem_name, version, gem_dir, mode, roots)` or an
|
|
18
|
+
# `Unresolvable(gem_name, reason)` value.
|
|
19
|
+
# - {Builder.build} folds a `Configuration::Dependencies`
|
|
20
|
+
# into a frozen {Index} carrying the partitioned outcomes.
|
|
21
|
+
# - {Index} holds the per-run state the dispatcher tier
|
|
22
|
+
# consults via `#contribution_for`. Slice 2a ships the
|
|
23
|
+
# stub returning `nil`; slice 2b populates the method
|
|
24
|
+
# table by walking each resolved gem's `roots:`.
|
|
25
|
+
#
|
|
26
|
+
# Per the ADR's "Implementation slicing" section, slice 2 is
|
|
27
|
+
# split internally:
|
|
28
|
+
#
|
|
29
|
+
# - Slice 2a (this commit): gem resolution, index plumbing,
|
|
30
|
+
# `Analysis::Runner` wiring, `dynamic.dependency-source.gem-not-found`
|
|
31
|
+
# diagnostic for unresolvable entries.
|
|
32
|
+
# - Slice 2b (next commit): walker, dispatcher tier
|
|
33
|
+
# integration, `Type::Dynamic`-wrapped returns.
|
|
34
|
+
module DependencySourceInference
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -12,6 +12,7 @@ require_relative "../inference/coverage_scanner"
|
|
|
12
12
|
require_relative "../inference/scope_indexer"
|
|
13
13
|
require_relative "../inference/method_dispatcher/file_folding"
|
|
14
14
|
require_relative "check_rules"
|
|
15
|
+
require_relative "dependency_source_inference"
|
|
15
16
|
require_relative "diagnostic"
|
|
16
17
|
require_relative "result"
|
|
17
18
|
|
|
@@ -21,7 +22,7 @@ module Rigor
|
|
|
21
22
|
RUBY_GLOB = "**/*.rb"
|
|
22
23
|
DEFAULT_CACHE_ROOT = ".rigor/cache"
|
|
23
24
|
|
|
24
|
-
attr_reader :cache_store, :plugin_registry
|
|
25
|
+
attr_reader :cache_store, :plugin_registry, :dependency_source_index
|
|
25
26
|
|
|
26
27
|
# @param configuration [Rigor::Configuration]
|
|
27
28
|
# @param explain [Boolean] surface fail-soft fallback events
|
|
@@ -40,6 +41,7 @@ module Rigor
|
|
|
40
41
|
@cache_store = cache_store
|
|
41
42
|
@plugin_requirer = plugin_requirer
|
|
42
43
|
@plugin_registry = Plugin::Registry::EMPTY
|
|
44
|
+
@dependency_source_index = DependencySourceInference::Index::EMPTY
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
# Walks every Ruby file under `paths`, parses it, builds a
|
|
@@ -58,22 +60,37 @@ module Rigor
|
|
|
58
60
|
return Result.new(diagnostics: [target_ruby_error]) if target_ruby_error
|
|
59
61
|
|
|
60
62
|
@plugin_registry = load_plugins
|
|
63
|
+
@dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
|
|
61
64
|
environment = Environment.for_project(
|
|
62
65
|
libraries: @configuration.libraries,
|
|
63
66
|
signature_paths: @configuration.signature_paths,
|
|
64
67
|
cache_store: @cache_store,
|
|
65
|
-
plugin_registry: @plugin_registry
|
|
68
|
+
plugin_registry: @plugin_registry,
|
|
69
|
+
dependency_source_index: @dependency_source_index
|
|
66
70
|
)
|
|
67
71
|
expansion = expand_paths(paths)
|
|
68
72
|
|
|
69
|
-
diagnostics =
|
|
70
|
-
diagnostics += plugin_prepare_diagnostics
|
|
71
|
-
diagnostics += expansion.fetch(:errors)
|
|
73
|
+
diagnostics = pre_file_diagnostics(expansion)
|
|
72
74
|
diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
|
|
73
75
|
|
|
74
76
|
Result.new(diagnostics: apply_severity_profile(diagnostics))
|
|
75
77
|
end
|
|
76
78
|
|
|
79
|
+
# Pre-file diagnostic streams that fire once per run rather
|
|
80
|
+
# than per analyzed file: plugin load / prepare envelopes,
|
|
81
|
+
# the ADR-10 dependency-source resolution surface, and the
|
|
82
|
+
# `expand_paths` errors for `paths:` entries that don't
|
|
83
|
+
# exist or aren't `.rb`. Aggregated here so `#run` stays
|
|
84
|
+
# under the ABC budget.
|
|
85
|
+
def pre_file_diagnostics(expansion)
|
|
86
|
+
plugin_load_diagnostics +
|
|
87
|
+
plugin_prepare_diagnostics +
|
|
88
|
+
dependency_source_diagnostics +
|
|
89
|
+
dependency_source_budget_diagnostics +
|
|
90
|
+
dependency_source_config_conflict_diagnostics +
|
|
91
|
+
expansion.fetch(:errors)
|
|
92
|
+
end
|
|
93
|
+
|
|
77
94
|
# `target_ruby` flows through to Prism's `version:` option.
|
|
78
95
|
# Prism enforces the supported range and raises
|
|
79
96
|
# `ArgumentError` for versions it does not recognise. Run a
|
|
@@ -207,6 +224,78 @@ module Rigor
|
|
|
207
224
|
end
|
|
208
225
|
end
|
|
209
226
|
|
|
227
|
+
# ADR-10 § "Diagnostic prefix family" — surfaces gems
|
|
228
|
+
# listed in `dependencies.source_inference` that RubyGems
|
|
229
|
+
# could not resolve. The run continues; the gem simply
|
|
230
|
+
# contributes nothing this session, mirroring the
|
|
231
|
+
# plugin-load error envelope. Authored `:warning` because
|
|
232
|
+
# an unresolvable gem usually means a typo or a missing
|
|
233
|
+
# `bundle install` rather than a project-blocking problem;
|
|
234
|
+
# the severity profile still re-stamps it.
|
|
235
|
+
def dependency_source_diagnostics
|
|
236
|
+
@dependency_source_index.unresolvable.map do |entry|
|
|
237
|
+
Diagnostic.new(
|
|
238
|
+
path: ".rigor.yml",
|
|
239
|
+
line: 1,
|
|
240
|
+
column: 1,
|
|
241
|
+
message: "dependencies.source_inference[].gem #{entry.gem_name.inspect} could not be " \
|
|
242
|
+
"resolved (#{entry.reason}); skipping",
|
|
243
|
+
severity: :warning,
|
|
244
|
+
rule: "dynamic.dependency-source.gem-not-found",
|
|
245
|
+
source_family: :builtin
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# ADR-10 § "Budget interaction" / slice 4 — emits one
|
|
251
|
+
# `:warning` per gem whose Walker run hit the
|
|
252
|
+
# `dependencies.budget_per_gem` cap. The cap is a Walker-
|
|
253
|
+
# side guard rail (slice 4 picks the (α) semantics from
|
|
254
|
+
# ADR-10 WD4: harvesting stops, the dispatcher behaves
|
|
255
|
+
# exactly as before for unrecorded methods). The
|
|
256
|
+
# diagnostic names the gem and points the user at the
|
|
257
|
+
# three remediations: ship RBS, reduce `mode:` from
|
|
258
|
+
# `full` to `when_missing`, or de-list the gem.
|
|
259
|
+
# ADR-10 § "config-conflict diagnostic" / 5d — surfaces
|
|
260
|
+
# `Configuration::Dependencies` warnings accumulated
|
|
261
|
+
# during `from_h` deduplication of the `includes:`-chain
|
|
262
|
+
# source_inference array. Each warning describes a
|
|
263
|
+
# per-gem mode conflict that the merge resolved
|
|
264
|
+
# right-wins; the user sees one diagnostic per conflict.
|
|
265
|
+
# `:warning` matches the user's "warn but don't block"
|
|
266
|
+
# preference per the design discussion.
|
|
267
|
+
def dependency_source_config_conflict_diagnostics
|
|
268
|
+
@configuration.dependencies.warnings.map do |message|
|
|
269
|
+
Diagnostic.new(
|
|
270
|
+
path: ".rigor.yml",
|
|
271
|
+
line: 1,
|
|
272
|
+
column: 1,
|
|
273
|
+
message: message,
|
|
274
|
+
severity: :warning,
|
|
275
|
+
rule: "dynamic.dependency-source.config-conflict",
|
|
276
|
+
source_family: :builtin
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def dependency_source_budget_diagnostics
|
|
282
|
+
budget = @configuration.dependencies.budget_per_gem
|
|
283
|
+
@dependency_source_index.budget_exceeded.map do |gem_name|
|
|
284
|
+
Diagnostic.new(
|
|
285
|
+
path: ".rigor.yml",
|
|
286
|
+
line: 1,
|
|
287
|
+
column: 1,
|
|
288
|
+
message: "dependencies.source_inference[].gem #{gem_name.inspect} exceeded the per-gem " \
|
|
289
|
+
"catalog cap (#{budget} method definitions); the remaining methods fall back " \
|
|
290
|
+
"to the existing RBS-or-Dynamic[top] boundary. Ship RBS for the gem, set " \
|
|
291
|
+
"`mode: when_missing` instead of `full`, or de-list the gem.",
|
|
292
|
+
severity: :warning,
|
|
293
|
+
rule: "dynamic.dependency-source.budget-exceeded",
|
|
294
|
+
source_family: :builtin
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
210
299
|
# ADR-9 slice 3 — invokes every loaded plugin's `#prepare`
|
|
211
300
|
# hook once per run, after the loader's `#init` pass and
|
|
212
301
|
# before per-file iteration. Plugins publish facts here
|