rigortype 0.1.4 → 0.1.5
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 +40 -13
- data/lib/rigor/analysis/fact_store.rb +15 -3
- data/lib/rigor/analysis/result.rb +11 -3
- data/lib/rigor/analysis/run_stats.rb +193 -0
- data/lib/rigor/analysis/runner.rb +387 -12
- data/lib/rigor/analysis/worker_session.rb +327 -0
- data/lib/rigor/builtins/imported_refinements.rb +6 -2
- data/lib/rigor/builtins/regex_refinement.rb +17 -12
- data/lib/rigor/cache/rbs_descriptor.rb +3 -1
- data/lib/rigor/cache/store.rb +40 -7
- data/lib/rigor/cli.rb +52 -2
- data/lib/rigor/configuration.rb +131 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
- data/lib/rigor/environment/class_registry.rb +12 -3
- data/lib/rigor/environment/lockfile_resolver.rb +125 -0
- data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
- data/lib/rigor/environment/rbs_loader.rb +194 -6
- data/lib/rigor/environment/reflection.rb +152 -0
- data/lib/rigor/environment.rb +78 -6
- data/lib/rigor/inference/acceptance.rb +35 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
- data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
- data/lib/rigor/inference/expression_typer.rb +12 -2
- data/lib/rigor/inference/macro_block_self_type.rb +96 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
- data/lib/rigor/inference/method_dispatcher.rb +128 -3
- data/lib/rigor/inference/method_parameter_binder.rb +21 -11
- data/lib/rigor/inference/narrowing.rb +127 -8
- data/lib/rigor/inference/synthetic_method.rb +86 -0
- data/lib/rigor/inference/synthetic_method_index.rb +82 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
- data/lib/rigor/plugin/blueprint.rb +60 -0
- data/lib/rigor/plugin/loader.rb +3 -1
- data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
- data/lib/rigor/plugin/macro/external_file.rb +143 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
- data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
- data/lib/rigor/plugin/macro.rb +31 -0
- data/lib/rigor/plugin/manifest.rb +78 -7
- data/lib/rigor/plugin/registry.rb +32 -2
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/trinary.rb +15 -11
- data/lib/rigor/type/bot.rb +6 -3
- data/lib/rigor/type/combinator.rb +12 -1
- data/lib/rigor/type/integer_range.rb +7 -7
- data/lib/rigor/type/refined.rb +18 -12
- data/lib/rigor/type/top.rb +4 -3
- data/lib/rigor/type_node/generic.rb +7 -1
- data/lib/rigor/type_node/identifier.rb +9 -1
- data/lib/rigor/type_node/string_literal.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +5 -2
- data/sig/rigor/plugin/blueprint.rbs +7 -0
- data/sig/rigor/plugin/manifest.rbs +1 -1
- data/sig/rigor/plugin/registry.rbs +14 -1
- data/sig/rigor.rbs +35 -2
- metadata +39 -1
data/lib/rigor/configuration.rb
CHANGED
|
@@ -63,6 +63,81 @@ module Rigor
|
|
|
63
63
|
"dependencies" => {
|
|
64
64
|
"source_inference" => [],
|
|
65
65
|
"budget_per_gem" => Configuration::Dependencies::DEFAULT_BUDGET_PER_GEM
|
|
66
|
+
},
|
|
67
|
+
"parallel" => {
|
|
68
|
+
# ADR-15 Phase 4c — when greater than zero, `rigor check`
|
|
69
|
+
# dispatches per-file analysis across N Ractor workers
|
|
70
|
+
# built around {Rigor::Analysis::WorkerSession}.
|
|
71
|
+
# `0` (default) keeps the sequential coordinator path
|
|
72
|
+
# bit-for-bit unchanged. The CLI's `--workers=N` flag
|
|
73
|
+
# and the `RIGOR_RACTOR_WORKERS` env var both override
|
|
74
|
+
# this setting; precedence is CLI > env > config > 0.
|
|
75
|
+
"workers" => 0
|
|
76
|
+
},
|
|
77
|
+
"bundler" => {
|
|
78
|
+
# Open item O4 — target-project Bundler awareness.
|
|
79
|
+
# When `bundle_path:` is set (or auto-detected), Rigor
|
|
80
|
+
# walks `<bundle_path>/ruby/*/gems/*/sig/` and adds each
|
|
81
|
+
# gem-shipped sig directory to `signature_paths:`. With
|
|
82
|
+
# O7's failure-memo in place, conflicts (a vendored sig
|
|
83
|
+
# already declares the same constant) degrade gracefully
|
|
84
|
+
# to "no RBS env" with a single-line warning naming the
|
|
85
|
+
# offending file, rather than hanging.
|
|
86
|
+
#
|
|
87
|
+
# `bundle_path:` (String, optional): explicit path to the
|
|
88
|
+
# bundler install root (e.g., "vendor/bundle" or an
|
|
89
|
+
# absolute path). Resolved relative to the project root
|
|
90
|
+
# (`paths:`'s base) when relative.
|
|
91
|
+
#
|
|
92
|
+
# `auto_detect:` (Boolean, default true): when no
|
|
93
|
+
# explicit `bundle_path:` is set, try `.bundle/config`'s
|
|
94
|
+
# `BUNDLE_PATH:` first; fall back to `vendor/bundle/`
|
|
95
|
+
# under the project root if it exists. When neither is
|
|
96
|
+
# found, no extra sigs are added — the analyzer sees
|
|
97
|
+
# only rigor's vendored RBS and the user's
|
|
98
|
+
# `signature_paths:`.
|
|
99
|
+
#
|
|
100
|
+
# O4 Layer 3 keys:
|
|
101
|
+
#
|
|
102
|
+
# `lockfile:` (String, optional): explicit path to a
|
|
103
|
+
# `Gemfile.lock`. Resolved relative to the project root
|
|
104
|
+
# when relative. When set (or auto-detected via the
|
|
105
|
+
# `auto_detect:` flag below) Rigor parses the lockfile
|
|
106
|
+
# and uses it to FILTER the bundle-discovered `sig/`
|
|
107
|
+
# directories: only gems whose `(name, version,
|
|
108
|
+
# platform)` matches a lockfile entry are admitted to
|
|
109
|
+
# `signature_paths:`. Stale or out-of-band gems sitting
|
|
110
|
+
# in the bundle install tree are silently dropped.
|
|
111
|
+
#
|
|
112
|
+
# `auto_detect:` (Boolean, also gates the lockfile
|
|
113
|
+
# search): when true and `lockfile:` is nil, look for
|
|
114
|
+
# `<project_root>/Gemfile.lock`.
|
|
115
|
+
"bundle_path" => nil,
|
|
116
|
+
"auto_detect" => true,
|
|
117
|
+
"lockfile" => nil
|
|
118
|
+
},
|
|
119
|
+
"rbs_collection" => {
|
|
120
|
+
# Open item O4 Layer 3 slice 2 — `rbs collection
|
|
121
|
+
# install` awareness. When the target project has been
|
|
122
|
+
# set up with `rbs collection install`, the resulting
|
|
123
|
+
# `rbs_collection.lock.yaml` carries the resolved (gem,
|
|
124
|
+
# version, source) triples and `.gem_rbs_collection/`
|
|
125
|
+
# holds the downloaded `.rbs` files. Rigor parses the
|
|
126
|
+
# lockfile and auto-feeds each gem's
|
|
127
|
+
# `<collection_root>/<name>/<version>/` directory into
|
|
128
|
+
# `RbsLoader`'s `signature_paths:`. Sources of type
|
|
129
|
+
# `stdlib` are skipped because rigor's bundled
|
|
130
|
+
# `DEFAULT_LIBRARIES` already covers that surface.
|
|
131
|
+
#
|
|
132
|
+
# `lockfile:` (String, optional): explicit path to
|
|
133
|
+
# `rbs_collection.lock.yaml`. Resolved relative to the
|
|
134
|
+
# project root when relative.
|
|
135
|
+
#
|
|
136
|
+
# `auto_detect:` (Boolean, default true): when no
|
|
137
|
+
# explicit `lockfile:` is set, look for
|
|
138
|
+
# `<project_root>/rbs_collection.lock.yaml`.
|
|
139
|
+
"lockfile" => nil,
|
|
140
|
+
"auto_detect" => true
|
|
66
141
|
}
|
|
67
142
|
}.freeze
|
|
68
143
|
|
|
@@ -78,7 +153,9 @@ module Rigor
|
|
|
78
153
|
:plugins_io_network, :plugins_io_allowed_paths,
|
|
79
154
|
:plugins_io_allowed_url_hosts,
|
|
80
155
|
:severity_profile, :severity_overrides,
|
|
81
|
-
:dependencies
|
|
156
|
+
:dependencies, :parallel_workers,
|
|
157
|
+
:bundler_bundle_path, :bundler_auto_detect, :bundler_lockfile,
|
|
158
|
+
:rbs_collection_lockfile, :rbs_collection_auto_detect
|
|
82
159
|
|
|
83
160
|
# Loads a configuration file.
|
|
84
161
|
#
|
|
@@ -214,13 +291,13 @@ module Rigor
|
|
|
214
291
|
private_class_method :load_with_includes, :merge_includes, :resolve_paths_in, :deep_merge,
|
|
215
292
|
:merge_value, :merge_dependencies_hash
|
|
216
293
|
|
|
217
|
-
# rubocop:disable Metrics/AbcSize
|
|
294
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
218
295
|
def initialize(data = DEFAULTS)
|
|
219
296
|
cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
|
|
220
297
|
plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
|
|
221
298
|
|
|
222
299
|
@target_ruby = coerce_target_ruby(data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")))
|
|
223
|
-
@paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s)
|
|
300
|
+
@paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s).freeze
|
|
224
301
|
user_excludes = Array(data.fetch("exclude", DEFAULTS.fetch("exclude"))).map(&:to_s)
|
|
225
302
|
@exclude_patterns = (BUILTIN_EXCLUDES + user_excludes).uniq.freeze
|
|
226
303
|
@plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map do |entry|
|
|
@@ -246,10 +323,32 @@ module Rigor
|
|
|
246
323
|
@dependencies = Dependencies.from_h(
|
|
247
324
|
data.fetch("dependencies", DEFAULTS.fetch("dependencies"))
|
|
248
325
|
)
|
|
326
|
+
parallel = DEFAULTS.fetch("parallel").merge(data.fetch("parallel", {}))
|
|
327
|
+
@parallel_workers = coerce_parallel_workers(parallel.fetch("workers"))
|
|
328
|
+
bundler = DEFAULTS.fetch("bundler").merge(data.fetch("bundler", {}))
|
|
329
|
+
bp = bundler.fetch("bundle_path")
|
|
330
|
+
@bundler_bundle_path = bp.nil? ? nil : bp.to_s.dup.freeze
|
|
331
|
+
@bundler_auto_detect = bundler.fetch("auto_detect") == true
|
|
332
|
+
lf = bundler.fetch("lockfile")
|
|
333
|
+
@bundler_lockfile = lf.nil? ? nil : lf.to_s.dup.freeze
|
|
334
|
+
rbs_collection = DEFAULTS.fetch("rbs_collection").merge(data.fetch("rbs_collection", {}))
|
|
335
|
+
rclf = rbs_collection.fetch("lockfile")
|
|
336
|
+
@rbs_collection_lockfile = rclf.nil? ? nil : rclf.to_s.dup.freeze
|
|
337
|
+
@rbs_collection_auto_detect = rbs_collection.fetch("auto_detect") == true
|
|
338
|
+
# Ractor migration Phase 2a: deep-freeze the
|
|
339
|
+
# Configuration so it is `Ractor.shareable?`. Every
|
|
340
|
+
# ivar above is now either a frozen value (Symbol /
|
|
341
|
+
# nil / Boolean) or an explicitly frozen
|
|
342
|
+
# collection / value object; freezing `self` makes the
|
|
343
|
+
# whole carrier safe to send across Ractor boundaries
|
|
344
|
+
# (and catches accidental post-init mutation in any
|
|
345
|
+
# caller). See
|
|
346
|
+
# `docs/design/20260514-ractor-migration.md`.
|
|
347
|
+
freeze
|
|
249
348
|
end
|
|
250
|
-
# rubocop:enable Metrics/AbcSize
|
|
349
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
251
350
|
|
|
252
|
-
def to_h
|
|
351
|
+
def to_h # rubocop:disable Metrics/MethodLength
|
|
253
352
|
{
|
|
254
353
|
"target_ruby" => target_ruby,
|
|
255
354
|
"paths" => paths,
|
|
@@ -269,7 +368,19 @@ module Rigor
|
|
|
269
368
|
},
|
|
270
369
|
"severity_profile" => severity_profile.to_s,
|
|
271
370
|
"severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] },
|
|
272
|
-
"dependencies" => dependencies.to_h
|
|
371
|
+
"dependencies" => dependencies.to_h,
|
|
372
|
+
"parallel" => {
|
|
373
|
+
"workers" => parallel_workers
|
|
374
|
+
},
|
|
375
|
+
"bundler" => {
|
|
376
|
+
"bundle_path" => bundler_bundle_path,
|
|
377
|
+
"auto_detect" => bundler_auto_detect,
|
|
378
|
+
"lockfile" => bundler_lockfile
|
|
379
|
+
},
|
|
380
|
+
"rbs_collection" => {
|
|
381
|
+
"lockfile" => rbs_collection_lockfile,
|
|
382
|
+
"auto_detect" => rbs_collection_auto_detect
|
|
383
|
+
}
|
|
273
384
|
}
|
|
274
385
|
end
|
|
275
386
|
|
|
@@ -327,6 +438,20 @@ module Rigor
|
|
|
327
438
|
VALID_NETWORK_POLICIES = %i[disabled allowlist].freeze
|
|
328
439
|
private_constant :VALID_NETWORK_POLICIES
|
|
329
440
|
|
|
441
|
+
# ADR-15 Phase 4c — accepts a non-negative Integer (or a
|
|
442
|
+
# string-shaped one from YAML files that miss type
|
|
443
|
+
# annotations). Negative / non-integer values raise so
|
|
444
|
+
# typos / bad YAML fail loudly rather than silently
|
|
445
|
+
# disabling parallelism.
|
|
446
|
+
def coerce_parallel_workers(value)
|
|
447
|
+
integer = Integer(value)
|
|
448
|
+
raise ArgumentError, "parallel.workers must be >= 0, got #{value.inspect}" if integer.negative?
|
|
449
|
+
|
|
450
|
+
integer
|
|
451
|
+
rescue TypeError, ArgumentError => e
|
|
452
|
+
raise ArgumentError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
|
|
453
|
+
end
|
|
454
|
+
|
|
330
455
|
def coerce_network_policy(value)
|
|
331
456
|
sym = value.to_sym
|
|
332
457
|
unless VALID_NETWORK_POLICIES.include?(sym)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class Environment
|
|
7
|
+
# Open item O4 — target-project Bundler awareness.
|
|
8
|
+
#
|
|
9
|
+
# Walks a Bundler-installed gem tree (e.g., the project's
|
|
10
|
+
# `vendor/bundle` or a Docker-mounted bundle root) and
|
|
11
|
+
# returns the per-gem `sig/` directories to feed into
|
|
12
|
+
# `RbsLoader`'s `signature_paths:`. Of the ~3% of gems that
|
|
13
|
+
# ship `sig/` in their gem package today (per the four-project
|
|
14
|
+
# Mastodon Docker bundle-install measurement on 2026-05-15:
|
|
15
|
+
# 10 of 343 gems shipped sig — `prism`, `aws-sdk-s3`,
|
|
16
|
+
# `aws-sdk-kms`, `aws-sdk-core`, `playwright-ruby-client`,
|
|
17
|
+
# `mutex_m`, `webrick`, `base64`, `stoplight`, `ffi`), this
|
|
18
|
+
# discovery surfaces the typed contract the gem author
|
|
19
|
+
# explicitly published.
|
|
20
|
+
#
|
|
21
|
+
# Conflicts with rigor's bundled stdlib RBS (the prism case
|
|
22
|
+
# was the motivating example) degrade gracefully via O7's
|
|
23
|
+
# failure-memo in `RbsLoader#env`: a single warning naming
|
|
24
|
+
# the offending file is emitted and analysis continues with
|
|
25
|
+
# `Dynamic[top]` everywhere rather than hanging.
|
|
26
|
+
#
|
|
27
|
+
# The discovery is intentionally a pure file-system walk —
|
|
28
|
+
# no `Bundler` API call, no `Gemfile.lock` parse — so rigor
|
|
29
|
+
# doesn't need the target project's Bundler context.
|
|
30
|
+
module BundleSigDiscovery
|
|
31
|
+
# Gems already covered by rigor's `DEFAULT_LIBRARIES`
|
|
32
|
+
# (stdlib RBS) plus the `data/vendored_gem_sigs/` bundle.
|
|
33
|
+
# Skipping these from bundle discovery prevents
|
|
34
|
+
# `RBS::DuplicatedDeclarationError` (the prism case was the
|
|
35
|
+
# motivating example — Ruby 4.0 ships prism's RBS in
|
|
36
|
+
# stdlib, and the gem also ships its own `sig/`, so loading
|
|
37
|
+
# both raises on `Prism::BACKEND` etc.).
|
|
38
|
+
#
|
|
39
|
+
# The list is hard-coded for the MVP because it tracks
|
|
40
|
+
# rigor's bundled coverage 1:1. When a new gem is vendored
|
|
41
|
+
# under `data/vendored_gem_sigs/` or added to
|
|
42
|
+
# `DEFAULT_LIBRARIES`, add its name here.
|
|
43
|
+
SKIPPED_GEMS_BY_DEFAULT = Set[
|
|
44
|
+
# DEFAULT_LIBRARIES (lib/rigor/environment.rb)
|
|
45
|
+
"pathname", "optparse", "json", "yaml", "fileutils",
|
|
46
|
+
"tempfile", "tmpdir", "stringio", "forwardable",
|
|
47
|
+
"digest", "securerandom", "uri", "logger", "date",
|
|
48
|
+
"pp", "delegate", "singleton", "observable", "abbrev",
|
|
49
|
+
"find", "tsort", "shellwords", "benchmark", "base64",
|
|
50
|
+
"did_you_mean", "monitor", "mutex_m", "timeout",
|
|
51
|
+
"open3", "erb", "etc", "ipaddr", "bigdecimal",
|
|
52
|
+
"bigdecimal-math", "prettyprint",
|
|
53
|
+
"random-formatter", "time", "open-uri", "resolv",
|
|
54
|
+
"csv", "pstore", "objspace", "io-console", "cgi", "cgi-escape",
|
|
55
|
+
"strscan",
|
|
56
|
+
"prism", "rbs",
|
|
57
|
+
# data/vendored_gem_sigs/
|
|
58
|
+
"pg", "mysql2", "nokogiri", "bcrypt", "redis", "idn-ruby"
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
# @param bundle_path [String, Pathname, nil] explicit path
|
|
62
|
+
# to the bundler install root. When `nil`, falls back to
|
|
63
|
+
# `auto_detect` if `auto_detect:` is true.
|
|
64
|
+
# @param project_root [String] resolution base for relative
|
|
65
|
+
# `bundle_path:` and the auto-detect search.
|
|
66
|
+
# @param auto_detect [Boolean] when true and `bundle_path:`
|
|
67
|
+
# is nil, try `.bundle/config`'s `BUNDLE_PATH:` and
|
|
68
|
+
# `vendor/bundle/` under `project_root`.
|
|
69
|
+
# @param skip_gems [Set<String>] gem names to exclude from
|
|
70
|
+
# discovery. Defaults to {SKIPPED_GEMS_BY_DEFAULT}.
|
|
71
|
+
# @param locked_gems [Hash{String => LockfileResolver::LockedGem}, nil]
|
|
72
|
+
# Optional O4-Layer-3 filter. When non-nil and non-empty,
|
|
73
|
+
# only `sig/` directories whose gem `(name, version,
|
|
74
|
+
# platform)` tuple matches a lockfile entry are returned.
|
|
75
|
+
# Bundle entries absent from the lockfile (or at a drifted
|
|
76
|
+
# version) are silently dropped — the lockfile is treated
|
|
77
|
+
# as the source of truth for "what gems this project
|
|
78
|
+
# actually declares". Pass `nil` (the default) to keep
|
|
79
|
+
# the pre-Layer-3 behaviour of returning every non-skipped
|
|
80
|
+
# `sig/` under the bundle.
|
|
81
|
+
# @return [Array<Pathname>] every `<gem-dir>/sig` directory
|
|
82
|
+
# under the resolved bundle path, minus any whose gem
|
|
83
|
+
# name is in `skip_gems` and (when `locked_gems` is
|
|
84
|
+
# supplied) minus any whose `(name, version, platform)`
|
|
85
|
+
# does not match a lockfile entry.
|
|
86
|
+
def self.discover(bundle_path:, project_root: Dir.pwd, auto_detect: true,
|
|
87
|
+
skip_gems: SKIPPED_GEMS_BY_DEFAULT, locked_gems: nil)
|
|
88
|
+
resolved = resolve_bundle_path(
|
|
89
|
+
bundle_path: bundle_path,
|
|
90
|
+
project_root: project_root,
|
|
91
|
+
auto_detect: auto_detect
|
|
92
|
+
)
|
|
93
|
+
return [] if resolved.nil?
|
|
94
|
+
|
|
95
|
+
# `<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig/` is the
|
|
96
|
+
# canonical bundler layout. `*` on the ruby version dir
|
|
97
|
+
# picks up whichever Ruby the bundle was installed for.
|
|
98
|
+
all = Dir.glob(resolved.join("ruby", "*", "gems", "*", "sig")).map { |d| Pathname.new(d) }
|
|
99
|
+
filtered = all.reject { |sig_dir| skip_gems.include?(gem_name_from_sig_path(sig_dir)) }
|
|
100
|
+
return filtered if locked_gems.nil? || locked_gems.empty?
|
|
101
|
+
|
|
102
|
+
expected_dirs = expected_gem_dirs(locked_gems)
|
|
103
|
+
filtered.select { |sig_dir| expected_dirs.include?(sig_dir.parent.basename.to_s) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# `{name => LockedGem}` → set of canonical bundler gem
|
|
107
|
+
# directory basenames. Pure-Ruby gems install as
|
|
108
|
+
# `<name>-<version>`; platform-specific gems install as
|
|
109
|
+
# `<name>-<version>-<platform>` (e.g. `ffi-1.17.4-aarch64-linux-gnu`).
|
|
110
|
+
# Lockfile platform `"ruby"` is the pure-Ruby case; any
|
|
111
|
+
# other value is treated as a platform tag.
|
|
112
|
+
def self.expected_gem_dirs(locked_gems)
|
|
113
|
+
locked_gems.each_value.with_object(Set.new) do |locked, set|
|
|
114
|
+
base = "#{locked.name}-#{locked.version}"
|
|
115
|
+
set << if locked.platform == "ruby" || locked.platform.empty?
|
|
116
|
+
base
|
|
117
|
+
else
|
|
118
|
+
"#{base}-#{locked.platform}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
private_class_method :expected_gem_dirs
|
|
123
|
+
|
|
124
|
+
# `<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig` → `<name>`.
|
|
125
|
+
# The gem directory follows the canonical
|
|
126
|
+
# `<name>-<version>` pattern; we strip everything from the
|
|
127
|
+
# last hyphen onwards to recover the name. (Platform-tagged
|
|
128
|
+
# variants like `ffi-1.17.4-aarch64-linux-gnu/` keep their
|
|
129
|
+
# platform suffix in the version part, so the first hyphen
|
|
130
|
+
# from the right is still the name boundary.)
|
|
131
|
+
#
|
|
132
|
+
# Public so the O4 Layer 3 slice-3 coverage report
|
|
133
|
+
# (`RbsCoverageReport`) can classify discovered bundle sigs
|
|
134
|
+
# against locked gem names without re-running discovery.
|
|
135
|
+
def self.gem_name_from_sig_path(sig_dir)
|
|
136
|
+
gem_dir = sig_dir.parent.basename.to_s
|
|
137
|
+
# Strip `-<version>` and any platform suffix. The version
|
|
138
|
+
# always starts with a digit, so split at the first
|
|
139
|
+
# `-` followed by a digit.
|
|
140
|
+
gem_dir.sub(/-\d.*\z/, "")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Returns `Pathname` resolved bundle path, or `nil` when
|
|
144
|
+
# neither explicit nor auto-detected. Public for the stats
|
|
145
|
+
# banner so end users can see what rigor picked up.
|
|
146
|
+
def self.resolve_bundle_path(bundle_path:, project_root: Dir.pwd, auto_detect: true)
|
|
147
|
+
if bundle_path
|
|
148
|
+
path = Pathname.new(File.expand_path(bundle_path.to_s, project_root))
|
|
149
|
+
return path if path.directory?
|
|
150
|
+
|
|
151
|
+
return nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
return nil unless auto_detect
|
|
155
|
+
|
|
156
|
+
detected = auto_detect(project_root: project_root)
|
|
157
|
+
Pathname.new(detected) if detected
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Auto-detection order:
|
|
161
|
+
# 1. `<project_root>/.bundle/config` carries `BUNDLE_PATH:`
|
|
162
|
+
# set by `bundle config set --local path <dir>`.
|
|
163
|
+
# 2. `<project_root>/vendor/bundle/` — the conventional
|
|
164
|
+
# in-tree install location when a developer ran
|
|
165
|
+
# `bundle install --path vendor/bundle`.
|
|
166
|
+
# 3. `nil` — let the caller proceed without bundle sig
|
|
167
|
+
# discovery (rigor's vendored RBS still loads).
|
|
168
|
+
def self.auto_detect(project_root:)
|
|
169
|
+
from_config = read_bundle_config_path(project_root)
|
|
170
|
+
return File.expand_path(from_config, project_root) if from_config
|
|
171
|
+
|
|
172
|
+
vendor = File.join(project_root, "vendor", "bundle")
|
|
173
|
+
return vendor if File.directory?(vendor)
|
|
174
|
+
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.read_bundle_config_path(project_root)
|
|
179
|
+
config_path = File.join(project_root, ".bundle", "config")
|
|
180
|
+
return nil unless File.exist?(config_path)
|
|
181
|
+
|
|
182
|
+
# `.bundle/config` is YAML with all-caps env-style keys.
|
|
183
|
+
# `BUNDLE_PATH:` is the canonical key (Bundler 2.x); the
|
|
184
|
+
# `--path` flag sets it.
|
|
185
|
+
data = YAML.safe_load_file(config_path)
|
|
186
|
+
return nil unless data.is_a?(Hash)
|
|
187
|
+
|
|
188
|
+
data["BUNDLE_PATH"]
|
|
189
|
+
rescue StandardError
|
|
190
|
+
# Malformed `.bundle/config` should not break analysis;
|
|
191
|
+
# silently skip auto-detection.
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private_class_method :read_bundle_config_path
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -67,10 +67,19 @@ module Rigor
|
|
|
67
67
|
|
|
68
68
|
private
|
|
69
69
|
|
|
70
|
+
# ADR-15 Phase 4b — the default registry MUST be
|
|
71
|
+
# `Ractor.shareable?` so worker Ractors that consult
|
|
72
|
+
# `Environment.for_project`'s default `class_registry:`
|
|
73
|
+
# don't trip `Ractor::IsolationError`. The internal
|
|
74
|
+
# `@nominals` / `@class_objects` Hashes are populated
|
|
75
|
+
# via `register`, then `Ractor.make_shareable`
|
|
76
|
+
# recursively freezes the registry, the two Hashes,
|
|
77
|
+
# and confirms every entry (Type::Nominal carriers +
|
|
78
|
+
# core Ruby classes) is itself shareable.
|
|
70
79
|
def build_default
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
registry = new
|
|
81
|
+
CORE_BUILT_INS.each { |klass| registry.register(klass) }
|
|
82
|
+
Ractor.make_shareable(registry)
|
|
74
83
|
end
|
|
75
84
|
end
|
|
76
85
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
class Environment
|
|
5
|
+
# Open item O4 Layer 3 — Gemfile.lock parse.
|
|
6
|
+
#
|
|
7
|
+
# Parses a target project's `Gemfile.lock` via Bundler's
|
|
8
|
+
# `LockfileParser` and exposes the locked gem set as a frozen
|
|
9
|
+
# `Hash[String, LockfileResolver::LockedGem]` keyed by gem
|
|
10
|
+
# name. Used by {Rigor::Environment::BundleSigDiscovery} as a
|
|
11
|
+
# filter so the discovered `sig/` directories under the
|
|
12
|
+
# bundler install root are limited to gems the project
|
|
13
|
+
# actually declares (and at the version it declared them).
|
|
14
|
+
#
|
|
15
|
+
# The resolver is intentionally read-only. It does NOT load
|
|
16
|
+
# the project's `Gemfile`, does NOT resolve dependencies,
|
|
17
|
+
# does NOT touch the network, and does NOT require the
|
|
18
|
+
# target project's Bundler context. It only reads bytes from
|
|
19
|
+
# the lockfile.
|
|
20
|
+
#
|
|
21
|
+
# Failure modes are deliberately quiet: a missing or
|
|
22
|
+
# malformed lockfile returns an empty map. The auto-detect
|
|
23
|
+
# path is the configuration default; users who want hard
|
|
24
|
+
# failures should pass an explicit `bundler.lockfile:` and
|
|
25
|
+
# check the result via the stats banner.
|
|
26
|
+
module LockfileResolver
|
|
27
|
+
# Frozen value object for one locked gem entry.
|
|
28
|
+
#
|
|
29
|
+
# `version` is the resolved version string (e.g. "8.0.1");
|
|
30
|
+
# `platform` is the lockfile's platform tag, normalised to
|
|
31
|
+
# `"ruby"` when the lockfile records `ruby` and to the
|
|
32
|
+
# raw String otherwise (e.g. "aarch64-linux-gnu").
|
|
33
|
+
LockedGem = Data.define(:name, :version, :platform) do
|
|
34
|
+
def initialize(name:, version:, platform:)
|
|
35
|
+
super(
|
|
36
|
+
name: -name.to_s,
|
|
37
|
+
version: -version.to_s,
|
|
38
|
+
platform: -platform.to_s
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param lockfile_path [String, Pathname, nil] explicit path
|
|
44
|
+
# to the Gemfile.lock. When `nil`, falls back to
|
|
45
|
+
# `auto_detect` if `auto_detect:` is true.
|
|
46
|
+
# @param project_root [String] resolution base for a
|
|
47
|
+
# relative `lockfile_path:` and the auto-detect search.
|
|
48
|
+
# @param auto_detect [Boolean] when true and
|
|
49
|
+
# `lockfile_path:` is nil, look for
|
|
50
|
+
# `<project_root>/Gemfile.lock`.
|
|
51
|
+
# @return [Hash{String => LockedGem}] frozen map of gem
|
|
52
|
+
# name → locked entry. Returns the empty frozen hash
|
|
53
|
+
# when no lockfile is resolvable, when the file is
|
|
54
|
+
# unreadable, or when Bundler refuses to parse it.
|
|
55
|
+
def self.locked_gems(lockfile_path:, project_root: Dir.pwd, auto_detect: true)
|
|
56
|
+
resolved = resolve_lockfile_path(
|
|
57
|
+
lockfile_path: lockfile_path,
|
|
58
|
+
project_root: project_root,
|
|
59
|
+
auto_detect: auto_detect
|
|
60
|
+
)
|
|
61
|
+
return EMPTY unless resolved
|
|
62
|
+
|
|
63
|
+
parse(resolved)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the resolved lockfile path (`Pathname`) or `nil`
|
|
67
|
+
# when neither explicit nor auto-detect produces one.
|
|
68
|
+
# Public so the stats banner can show what rigor picked up.
|
|
69
|
+
def self.resolve_lockfile_path(lockfile_path:, project_root: Dir.pwd, auto_detect: true)
|
|
70
|
+
if lockfile_path
|
|
71
|
+
path = Pathname.new(File.expand_path(lockfile_path.to_s, project_root))
|
|
72
|
+
return path if path.file?
|
|
73
|
+
|
|
74
|
+
return nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return nil unless auto_detect
|
|
78
|
+
|
|
79
|
+
candidate = Pathname.new(File.join(project_root, "Gemfile.lock"))
|
|
80
|
+
candidate.file? ? candidate : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
EMPTY = {}.freeze
|
|
84
|
+
private_constant :EMPTY
|
|
85
|
+
|
|
86
|
+
# Parses a Gemfile.lock at the given path. Bundler load
|
|
87
|
+
# errors and malformed lockfile bytes both surface as the
|
|
88
|
+
# empty frozen hash; analysis must not crash because a
|
|
89
|
+
# lockfile is malformed. A single warning is emitted to
|
|
90
|
+
# `$stderr` so the user can see why their lockfile was
|
|
91
|
+
# ignored.
|
|
92
|
+
def self.parse(path)
|
|
93
|
+
require "bundler"
|
|
94
|
+
rescue LoadError => e
|
|
95
|
+
warn "rigor: cannot read #{path}: bundler is not available (#{e.message})"
|
|
96
|
+
EMPTY
|
|
97
|
+
else
|
|
98
|
+
do_parse(path)
|
|
99
|
+
end
|
|
100
|
+
private_class_method :parse
|
|
101
|
+
|
|
102
|
+
def self.do_parse(path)
|
|
103
|
+
body = File.read(path.to_s)
|
|
104
|
+
parser = Bundler::LockfileParser.new(body)
|
|
105
|
+
locked = parser.specs.each_with_object({}) do |spec, h|
|
|
106
|
+
# `Bundler::LazySpecification` carries name, version,
|
|
107
|
+
# platform. Platform is `Gem::Platform` or the symbol
|
|
108
|
+
# `:ruby`; both stringify cleanly. The upstream
|
|
109
|
+
# bundler RBS shim (references/rbs/sig/shims/bundler.rbs)
|
|
110
|
+
# does NOT declare `LazySpecification#platform` so the
|
|
111
|
+
# call site needs a suppression marker.
|
|
112
|
+
platform = spec.platform.to_s # rigor:disable undefined-method
|
|
113
|
+
h[spec.name.to_s] = LockedGem.new(
|
|
114
|
+
name: spec.name, version: spec.version.to_s, platform: platform
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
locked.freeze
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
warn "rigor: ignoring malformed #{path} (#{e.class}: #{e.message})"
|
|
120
|
+
EMPTY
|
|
121
|
+
end
|
|
122
|
+
private_class_method :do_parse
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class Environment
|
|
7
|
+
# Open item O4 Layer 3 slice 2 — `rbs collection install`
|
|
8
|
+
# awareness.
|
|
9
|
+
#
|
|
10
|
+
# When the target project has been set up with `rbs
|
|
11
|
+
# collection install` (the standard RBS-ecosystem flow for
|
|
12
|
+
# pulling community RBS from
|
|
13
|
+
# https://github.com/ruby/gem_rbs_collection), a
|
|
14
|
+
# `rbs_collection.lock.yaml` records the resolved (gem,
|
|
15
|
+
# version, source) triples and `.gem_rbs_collection/<name>/
|
|
16
|
+
# <version>/` carries the actual `.rbs` files. This module
|
|
17
|
+
# parses the lockfile and returns the per-gem RBS directory
|
|
18
|
+
# paths so they can be appended to `RbsLoader`'s
|
|
19
|
+
# `signature_paths:`.
|
|
20
|
+
#
|
|
21
|
+
# The discovery is intentionally a pure file-system + YAML
|
|
22
|
+
# walk — no Bundler API call, no network access. Failure
|
|
23
|
+
# modes (missing lockfile, malformed YAML, missing
|
|
24
|
+
# collection directory) silently degrade to an empty list.
|
|
25
|
+
module RbsCollectionDiscovery
|
|
26
|
+
# `stdlib`-typed entries in the lockfile are loaded into
|
|
27
|
+
# the RBS environment by the standard library mechanism
|
|
28
|
+
# (rigor's `Environment::DEFAULT_LIBRARIES` already covers
|
|
29
|
+
# this surface). Including them as `signature_paths:`
|
|
30
|
+
# entries would risk `RBS::DuplicatedDeclarationError`
|
|
31
|
+
# (the same hazard O7's failure-memo handles). The other
|
|
32
|
+
# documented source types — `git` (the gem_rbs_collection
|
|
33
|
+
# repo), `rubygems` (sigs lifted from a gem's bundled
|
|
34
|
+
# `sig/`), and `local` (a user-managed RBS dir) — all
|
|
35
|
+
# produce a directory under the collection root and are
|
|
36
|
+
# admitted.
|
|
37
|
+
SKIPPED_SOURCE_TYPES = Set["stdlib"].freeze
|
|
38
|
+
|
|
39
|
+
DEFAULT_COLLECTION_PATH = ".gem_rbs_collection"
|
|
40
|
+
private_constant :DEFAULT_COLLECTION_PATH
|
|
41
|
+
|
|
42
|
+
# @param lockfile_path [String, Pathname, nil] explicit
|
|
43
|
+
# path to `rbs_collection.lock.yaml`. When `nil`, falls
|
|
44
|
+
# back to `auto_detect` if `auto_detect:` is true.
|
|
45
|
+
# @param project_root [String] resolution base for
|
|
46
|
+
# relative `lockfile_path:` and the auto-detect search.
|
|
47
|
+
# @param auto_detect [Boolean] when true and
|
|
48
|
+
# `lockfile_path:` is nil, look for
|
|
49
|
+
# `<project_root>/rbs_collection.lock.yaml`.
|
|
50
|
+
# @return [Array<Pathname>] every
|
|
51
|
+
# `<collection_path>/<gem-name>/<gem-version>/`
|
|
52
|
+
# directory listed in the lockfile whose entry has a
|
|
53
|
+
# non-skipped source type and whose directory exists on
|
|
54
|
+
# disk. Returns `[]` when no lockfile is resolvable,
|
|
55
|
+
# when the YAML is unreadable, or when the collection
|
|
56
|
+
# path doesn't exist.
|
|
57
|
+
def self.discover(lockfile_path:, project_root: Dir.pwd, auto_detect: true)
|
|
58
|
+
resolved = resolve_lockfile_path(
|
|
59
|
+
lockfile_path: lockfile_path,
|
|
60
|
+
project_root: project_root,
|
|
61
|
+
auto_detect: auto_detect
|
|
62
|
+
)
|
|
63
|
+
return [] if resolved.nil?
|
|
64
|
+
|
|
65
|
+
data = read_lockfile_yaml(resolved)
|
|
66
|
+
return [] if data.nil?
|
|
67
|
+
|
|
68
|
+
collection_root = resolve_collection_root(resolved, data)
|
|
69
|
+
return [] unless collection_root.directory?
|
|
70
|
+
|
|
71
|
+
gem_paths_from(collection_root, data)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns the resolved lockfile path (`Pathname`) or `nil`
|
|
75
|
+
# when neither explicit nor auto-detect produces one.
|
|
76
|
+
# Public so the stats banner can surface what rigor found.
|
|
77
|
+
def self.resolve_lockfile_path(lockfile_path:, project_root: Dir.pwd, auto_detect: true)
|
|
78
|
+
if lockfile_path
|
|
79
|
+
path = Pathname.new(File.expand_path(lockfile_path.to_s, project_root))
|
|
80
|
+
return path if path.file?
|
|
81
|
+
|
|
82
|
+
return nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
return nil unless auto_detect
|
|
86
|
+
|
|
87
|
+
candidate = Pathname.new(File.join(project_root, "rbs_collection.lock.yaml"))
|
|
88
|
+
candidate.file? ? candidate : nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.read_lockfile_yaml(path)
|
|
92
|
+
data = YAML.safe_load_file(path.to_s, aliases: false)
|
|
93
|
+
data.is_a?(Hash) ? data : nil
|
|
94
|
+
rescue StandardError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
private_class_method :read_lockfile_yaml
|
|
98
|
+
|
|
99
|
+
def self.resolve_collection_root(lockfile_pathname, data)
|
|
100
|
+
rel = data["path"]
|
|
101
|
+
rel = DEFAULT_COLLECTION_PATH if rel.nil? || rel.to_s.empty?
|
|
102
|
+
# `path:` is documented as relative to the directory
|
|
103
|
+
# holding the lockfile (RBS::Collection::Config::Lockfile#fullpath).
|
|
104
|
+
lockfile_pathname.parent + Pathname.new(rel.to_s)
|
|
105
|
+
end
|
|
106
|
+
private_class_method :resolve_collection_root
|
|
107
|
+
|
|
108
|
+
def self.gem_paths_from(collection_root, data)
|
|
109
|
+
Array(data["gems"]).filter_map do |entry|
|
|
110
|
+
next unless entry.is_a?(Hash)
|
|
111
|
+
|
|
112
|
+
source_type = entry.dig("source", "type").to_s
|
|
113
|
+
next if SKIPPED_SOURCE_TYPES.include?(source_type)
|
|
114
|
+
|
|
115
|
+
name = entry["name"]
|
|
116
|
+
version = entry["version"]
|
|
117
|
+
next if name.nil? || version.nil?
|
|
118
|
+
|
|
119
|
+
gem_root = collection_root + name.to_s + version.to_s
|
|
120
|
+
gem_root if gem_root.directory?
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
private_class_method :gem_paths_from
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|