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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -13
  3. data/lib/rigor/analysis/fact_store.rb +15 -3
  4. data/lib/rigor/analysis/result.rb +11 -3
  5. data/lib/rigor/analysis/run_stats.rb +193 -0
  6. data/lib/rigor/analysis/runner.rb +387 -12
  7. data/lib/rigor/analysis/worker_session.rb +327 -0
  8. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  9. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  10. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  11. data/lib/rigor/cache/store.rb +40 -7
  12. data/lib/rigor/cli.rb +52 -2
  13. data/lib/rigor/configuration.rb +131 -6
  14. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  15. data/lib/rigor/environment/class_registry.rb +12 -3
  16. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  17. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  18. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  19. data/lib/rigor/environment/rbs_loader.rb +194 -6
  20. data/lib/rigor/environment/reflection.rb +152 -0
  21. data/lib/rigor/environment.rb +78 -6
  22. data/lib/rigor/inference/acceptance.rb +35 -1
  23. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  24. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  25. data/lib/rigor/inference/expression_typer.rb +12 -2
  26. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  27. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  28. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  29. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -1
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  32. data/lib/rigor/inference/method_dispatcher.rb +128 -3
  33. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  34. data/lib/rigor/inference/narrowing.rb +127 -8
  35. data/lib/rigor/inference/synthetic_method.rb +86 -0
  36. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  37. data/lib/rigor/inference/synthetic_method_scanner.rb +521 -0
  38. data/lib/rigor/plugin/blueprint.rb +60 -0
  39. data/lib/rigor/plugin/loader.rb +3 -1
  40. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  41. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  42. data/lib/rigor/plugin/macro/heredoc_template.rb +201 -0
  43. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  44. data/lib/rigor/plugin/macro.rb +31 -0
  45. data/lib/rigor/plugin/manifest.rb +78 -7
  46. data/lib/rigor/plugin/registry.rb +32 -2
  47. data/lib/rigor/plugin.rb +1 -0
  48. data/lib/rigor/trinary.rb +15 -11
  49. data/lib/rigor/type/bot.rb +6 -3
  50. data/lib/rigor/type/combinator.rb +12 -1
  51. data/lib/rigor/type/integer_range.rb +7 -7
  52. data/lib/rigor/type/refined.rb +18 -12
  53. data/lib/rigor/type/top.rb +4 -3
  54. data/lib/rigor/type_node/generic.rb +7 -1
  55. data/lib/rigor/type_node/identifier.rb +9 -1
  56. data/lib/rigor/type_node/string_literal.rb +4 -1
  57. data/lib/rigor/version.rb +1 -1
  58. data/sig/rigor/environment.rbs +5 -2
  59. data/sig/rigor/plugin/blueprint.rbs +7 -0
  60. data/sig/rigor/plugin/manifest.rbs +1 -1
  61. data/sig/rigor/plugin/registry.rbs +14 -1
  62. data/sig/rigor.rbs +35 -2
  63. metadata +39 -1
@@ -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
- new.tap do |registry|
72
- CORE_BUILT_INS.each { |klass| registry.register(klass) }
73
- end.freeze
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