scint 0.1.0

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 (72) hide show
  1. checksums.yaml +7 -0
  2. data/FEATURES.md +13 -0
  3. data/README.md +216 -0
  4. data/bin/bundler-vs-scint +233 -0
  5. data/bin/scint +35 -0
  6. data/bin/scint-io-summary +46 -0
  7. data/bin/scint-syscall-trace +41 -0
  8. data/lib/bundler/setup.rb +5 -0
  9. data/lib/bundler.rb +168 -0
  10. data/lib/scint/cache/layout.rb +131 -0
  11. data/lib/scint/cache/metadata_store.rb +75 -0
  12. data/lib/scint/cache/prewarm.rb +192 -0
  13. data/lib/scint/cli/add.rb +85 -0
  14. data/lib/scint/cli/cache.rb +316 -0
  15. data/lib/scint/cli/exec.rb +150 -0
  16. data/lib/scint/cli/install.rb +1047 -0
  17. data/lib/scint/cli/remove.rb +60 -0
  18. data/lib/scint/cli.rb +77 -0
  19. data/lib/scint/commands/exec.rb +17 -0
  20. data/lib/scint/commands/install.rb +17 -0
  21. data/lib/scint/credentials.rb +153 -0
  22. data/lib/scint/debug/io_trace.rb +218 -0
  23. data/lib/scint/debug/sampler.rb +138 -0
  24. data/lib/scint/downloader/fetcher.rb +113 -0
  25. data/lib/scint/downloader/pool.rb +112 -0
  26. data/lib/scint/errors.rb +63 -0
  27. data/lib/scint/fs.rb +119 -0
  28. data/lib/scint/gem/extractor.rb +86 -0
  29. data/lib/scint/gem/package.rb +62 -0
  30. data/lib/scint/gemfile/dependency.rb +30 -0
  31. data/lib/scint/gemfile/editor.rb +93 -0
  32. data/lib/scint/gemfile/parser.rb +275 -0
  33. data/lib/scint/index/cache.rb +166 -0
  34. data/lib/scint/index/client.rb +301 -0
  35. data/lib/scint/index/parser.rb +142 -0
  36. data/lib/scint/installer/extension_builder.rb +264 -0
  37. data/lib/scint/installer/linker.rb +226 -0
  38. data/lib/scint/installer/planner.rb +140 -0
  39. data/lib/scint/installer/preparer.rb +207 -0
  40. data/lib/scint/lockfile/parser.rb +251 -0
  41. data/lib/scint/lockfile/writer.rb +178 -0
  42. data/lib/scint/platform.rb +71 -0
  43. data/lib/scint/progress.rb +579 -0
  44. data/lib/scint/resolver/provider.rb +230 -0
  45. data/lib/scint/resolver/resolver.rb +249 -0
  46. data/lib/scint/runtime/exec.rb +141 -0
  47. data/lib/scint/runtime/setup.rb +45 -0
  48. data/lib/scint/scheduler.rb +392 -0
  49. data/lib/scint/source/base.rb +46 -0
  50. data/lib/scint/source/git.rb +92 -0
  51. data/lib/scint/source/path.rb +70 -0
  52. data/lib/scint/source/rubygems.rb +79 -0
  53. data/lib/scint/vendor/pub_grub/assignment.rb +20 -0
  54. data/lib/scint/vendor/pub_grub/basic_package_source.rb +169 -0
  55. data/lib/scint/vendor/pub_grub/failure_writer.rb +182 -0
  56. data/lib/scint/vendor/pub_grub/incompatibility.rb +150 -0
  57. data/lib/scint/vendor/pub_grub/package.rb +43 -0
  58. data/lib/scint/vendor/pub_grub/partial_solution.rb +121 -0
  59. data/lib/scint/vendor/pub_grub/rubygems.rb +45 -0
  60. data/lib/scint/vendor/pub_grub/solve_failure.rb +19 -0
  61. data/lib/scint/vendor/pub_grub/static_package_source.rb +61 -0
  62. data/lib/scint/vendor/pub_grub/strategy.rb +42 -0
  63. data/lib/scint/vendor/pub_grub/term.rb +105 -0
  64. data/lib/scint/vendor/pub_grub/version.rb +3 -0
  65. data/lib/scint/vendor/pub_grub/version_constraint.rb +129 -0
  66. data/lib/scint/vendor/pub_grub/version_range.rb +423 -0
  67. data/lib/scint/vendor/pub_grub/version_solver.rb +236 -0
  68. data/lib/scint/vendor/pub_grub/version_union.rb +178 -0
  69. data/lib/scint/vendor/pub_grub.rb +32 -0
  70. data/lib/scint/worker_pool.rb +114 -0
  71. data/lib/scint.rb +87 -0
  72. metadata +116 -0
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scint
4
+ module Resolver
5
+ # Bridges compact index data and the PubGrub resolver.
6
+ # Converts compact index info entries into version lists and dependency hashes.
7
+ #
8
+ # Supports multiple sources: each gem can be routed to a specific
9
+ # Index::Client based on source_map (gem_name => source_uri).
10
+ class Provider
11
+ # default_client: Index::Client for the default source (rubygems.org)
12
+ # clients: hash { source_uri_string => Index::Client } for all sources
13
+ # source_map: hash { gem_name => source_uri_string } for gems with explicit sources
14
+ # path_gems: hash { gem_name => { version:, dependencies: } } for path/git gems
15
+ # locked_specs: hash { name => version_string } for locked version preferences
16
+ # platforms: array of platform strings to match against
17
+ def initialize(default_client, clients: {}, source_map: {}, path_gems: {},
18
+ locked_specs: {}, platforms: nil)
19
+ @default_client = default_client
20
+ @clients = clients
21
+ @source_map = source_map
22
+ @path_gems = path_gems
23
+ @locked_specs = locked_specs
24
+ @platforms = platforms || default_platforms
25
+ @versions_cache = {}
26
+ @deps_cache = {}
27
+ @info_cache = {}
28
+ @mutex = Thread::Mutex.new
29
+ end
30
+
31
+ # For backward compat and use in resolver (e.g. source_uri on resolved specs)
32
+ def index_client
33
+ @default_client
34
+ end
35
+
36
+ # Returns the source URI for a gem name.
37
+ def source_uri_for(name)
38
+ if @path_gems.key?(name)
39
+ @path_gems[name][:source] || "path"
40
+ elsif @source_map.key?(name)
41
+ @source_map[name]
42
+ else
43
+ @default_client.source_uri
44
+ end
45
+ end
46
+
47
+ # Returns the Index::Client for a given gem name.
48
+ def client_for(name)
49
+ source_uri = @source_map[name]
50
+ if source_uri
51
+ @clients[source_uri] || @default_client
52
+ else
53
+ @default_client
54
+ end
55
+ end
56
+
57
+ # Returns sorted array of Gem::Version for a given gem name,
58
+ # filtered to matching platforms.
59
+ def versions_for(name)
60
+ @versions_cache[name] ||= begin
61
+ # Path/git gems: return the single known version
62
+ if @path_gems.key?(name)
63
+ pg = @path_gems[name]
64
+ [Gem::Version.new(pg[:version] || "0")]
65
+ else
66
+ entries = info_for(name)
67
+ versions = {}
68
+ entries.each do |_name, version, platform, _deps, _reqs|
69
+ next unless platform_match?(platform)
70
+ ver = Gem::Version.new(version)
71
+ versions[version] ||= ver
72
+ end
73
+ versions.values.sort
74
+ end
75
+ end
76
+ end
77
+
78
+ # Returns dependency hash for a specific gem name + version.
79
+ # { "dep_name" => Gem::Requirement, ... }
80
+ def dependencies_for(name, version)
81
+ key = "#{name}-#{version}"
82
+ @deps_cache[key] ||= begin
83
+ # Path/git gems: return their declared dependencies
84
+ if @path_gems.key?(name)
85
+ pg = @path_gems[name]
86
+ deps = {}
87
+ (pg[:dependencies] || []).each do |dep_name, dep_req_str|
88
+ deps[dep_name] = Gem::Requirement.new(dep_req_str || ">= 0")
89
+ end
90
+ deps
91
+ else
92
+ version_str = version.to_s
93
+ entries = info_for(name)
94
+ deps = {}
95
+
96
+ entries.each do |_name, ver, platform, dep_hash, _reqs|
97
+ next unless ver == version_str && platform_match?(platform)
98
+ dep_hash.each do |dep_name, dep_req_str|
99
+ # Merge constraints from all matching platform entries
100
+ req = Gem::Requirement.new(dep_req_str.split(", "))
101
+ deps[dep_name] = if deps[dep_name]
102
+ merge_requirements(deps[dep_name], req)
103
+ else
104
+ req
105
+ end
106
+ end
107
+ end
108
+
109
+ deps
110
+ end
111
+ end
112
+ end
113
+
114
+ # Check if a gem version has native extensions.
115
+ # Approximated by checking if there's a platform-specific variant.
116
+ def has_extensions?(name, version)
117
+ return false if @path_gems.key?(name)
118
+
119
+ version_str = version.to_s
120
+ entries = info_for(name)
121
+ has_platform_specific = false
122
+ entries.each do |_name, ver, platform, _deps, _reqs|
123
+ next unless ver == version_str
124
+ has_platform_specific = true if platform != "ruby"
125
+ end
126
+ has_platform_specific
127
+ end
128
+
129
+ # Choose the most appropriate platform for a resolved version,
130
+ # preferring local binary gems over ruby source gems when available.
131
+ def preferred_platform_for(name, version)
132
+ return "ruby" if @path_gems.key?(name)
133
+
134
+ version_str = version.to_s
135
+ entries = info_for(name).select do |_n, ver, platform, _deps, _reqs|
136
+ ver == version_str && platform_match?(platform)
137
+ end
138
+ return "ruby" if entries.empty?
139
+
140
+ local_platforms = @platforms.reject { |p| p == "ruby" }
141
+ non_ruby = entries.map { |e| e[2] }.compact.reject { |p| p == "ruby" }.uniq
142
+
143
+ preferred = nil
144
+ best_score = nil
145
+ non_ruby.each do |platform|
146
+ local_platforms.each_with_index do |local, index|
147
+ spec_plat = platform.is_a?(Gem::Platform) ? platform : Gem::Platform.new(platform)
148
+ local_plat = local.is_a?(Gem::Platform) ? local : Gem::Platform.new(local)
149
+ next unless spec_plat === local_plat
150
+
151
+ exact = spec_plat.to_s == local_plat.to_s ? 1 : 0
152
+ score = [exact, -index]
153
+ next if best_score && score <= best_score
154
+
155
+ best_score = score
156
+ preferred = platform
157
+ end
158
+ end
159
+ return preferred if preferred
160
+
161
+ entries.any? { |e| e[2] == "ruby" } ? "ruby" : entries.first[2].to_s
162
+ end
163
+
164
+ # Returns the locked version for a gem, if any.
165
+ def locked_version(name)
166
+ v = @locked_specs[name]
167
+ v ? Gem::Version.new(v) : nil
168
+ end
169
+
170
+ # Prefetch info for a batch of gem names, routing to the correct client.
171
+ def prefetch(names)
172
+ # Filter to names we haven't cached yet, skip path/git gems
173
+ uncached = names.reject { |n| @info_cache.key?(n) || @path_gems.key?(n) }
174
+ return if uncached.empty?
175
+
176
+ # Group by client
177
+ by_client = Hash.new { |h, k| h[k] = [] }
178
+ uncached.each do |name|
179
+ by_client[client_for(name)] << name
180
+ end
181
+
182
+ by_client.each do |client, client_names|
183
+ results = client.prefetch(client_names)
184
+ next unless results
185
+
186
+ @mutex.synchronize do
187
+ results.each do |name, data|
188
+ @info_cache[name] = data
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ # Returns true if the gem is a path or git source gem.
195
+ def path_or_git_gem?(name)
196
+ @path_gems.key?(name)
197
+ end
198
+
199
+ private
200
+
201
+ def info_for(name)
202
+ @info_cache[name] ||= client_for(name).fetch_info(name)
203
+ end
204
+
205
+ def default_platforms
206
+ local = Platform.local_platform.to_s
207
+ platforms = ["ruby"]
208
+ platforms << local unless local == "ruby"
209
+ platforms
210
+ end
211
+
212
+ def platform_match?(spec_platform)
213
+ return true if spec_platform.nil? || spec_platform == "ruby"
214
+ spec_plat = spec_platform.is_a?(Gem::Platform) ? spec_platform : Gem::Platform.new(spec_platform)
215
+ @platforms.any? do |plat|
216
+ next false if plat == "ruby"
217
+
218
+ local_plat = plat.is_a?(Gem::Platform) ? plat : Gem::Platform.new(plat)
219
+ spec_plat === local_plat
220
+ end
221
+ end
222
+
223
+ def merge_requirements(req1, req2)
224
+ # Combine requirement constraints
225
+ combined = req1.requirements + req2.requirements
226
+ Gem::Requirement.new(combined.map { |op, v| "#{op} #{v}" })
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../vendor/pub_grub"
4
+ require_relative "provider"
5
+
6
+ module Scint
7
+ module Resolver
8
+ # ResolvedSpec: the output of resolution.
9
+ ResolvedSpec = Struct.new(
10
+ :name, :version, :platform, :dependencies,
11
+ :source, :has_extensions, :remote_uri, :checksum,
12
+ keyword_init: true
13
+ )
14
+
15
+ # PubGrub-based dependency resolver.
16
+ # Implements the source interface that PubGrub::VersionSolver expects.
17
+ class Resolver
18
+ attr_reader :provider
19
+
20
+ # provider: Resolver::Provider instance
21
+ # dependencies: array of Gemfile::Dependency (top-level requirements)
22
+ # locked_specs: hash { name => version_string } for preferring locked versions
23
+ def initialize(provider:, dependencies:, locked_specs: {})
24
+ @provider = provider
25
+ @dependencies = dependencies
26
+ @locked_specs = locked_specs
27
+ @packages = {} # name => PubGrub::Package
28
+
29
+ @root_package = Scint::PubGrub::Package.root
30
+ @root_version = Scint::PubGrub::Package.root_version
31
+
32
+ # MUST be ascending — PubGrub uses binary search in select_versions.
33
+ @sorted_versions = Hash.new do |h, k|
34
+ if k == @root_package
35
+ h[k] = [@root_version]
36
+ else
37
+ h[k] = all_versions_for(k).sort
38
+ end
39
+ end
40
+
41
+ @cached_dependencies = Hash.new do |packages, package|
42
+ if package == @root_package
43
+ packages[package] = { @root_version => root_dependencies }
44
+ else
45
+ packages[package] = Hash.new do |versions, version|
46
+ versions[version] = dependencies_for(package, version)
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ # Run resolution. Returns array of ResolvedSpec.
53
+ def resolve
54
+ # Prefetch all known gem info before resolution
55
+ prefetch_all
56
+
57
+ solver = Scint::PubGrub::VersionSolver.new(
58
+ source: self,
59
+ root: @root_package,
60
+ logger: NullLogger.new
61
+ )
62
+
63
+ result = solver.solve
64
+
65
+ result.filter_map do |package, version|
66
+ next if Scint::PubGrub::Package.root?(package)
67
+ build_resolved_spec(package, version)
68
+ end
69
+ end
70
+
71
+ # --- PubGrub source interface ---
72
+
73
+ def versions_for(package, range = Scint::PubGrub::VersionRange.any)
74
+ range.select_versions(@sorted_versions[package])
75
+ end
76
+
77
+ def incompatibilities_for(package, version)
78
+ package_deps = @cached_dependencies[package]
79
+ sorted_versions = @sorted_versions[package]
80
+ package_deps[version].map do |dep_package, dep_constraint|
81
+ low = high = sorted_versions.index(version)
82
+
83
+ # find version low such that all >= low share the same dep
84
+ while low > 0 && package_deps[sorted_versions[low - 1]][dep_package] == dep_constraint
85
+ low -= 1
86
+ end
87
+ low = low == 0 ? nil : sorted_versions[low]
88
+
89
+ # find version high such that all < high share the same dep
90
+ while high < sorted_versions.length && package_deps[sorted_versions[high]][dep_package] == dep_constraint
91
+ high += 1
92
+ end
93
+ high = high == sorted_versions.length ? nil : sorted_versions[high]
94
+
95
+ range = Scint::PubGrub::VersionRange.new(min: low, max: high, include_min: !low.nil?)
96
+ self_constraint = Scint::PubGrub::VersionConstraint.new(package, range: range)
97
+
98
+ dep_term = Scint::PubGrub::Term.new(dep_constraint, false)
99
+ self_term = Scint::PubGrub::Term.new(self_constraint, true)
100
+
101
+ Scint::PubGrub::Incompatibility.new([self_term, dep_term], cause: :dependency)
102
+ end
103
+ end
104
+
105
+ def no_versions_incompatibility_for(_package, unsatisfied_term)
106
+ cause = Scint::PubGrub::Incompatibility::NoVersions.new(unsatisfied_term)
107
+ Scint::PubGrub::Incompatibility.new([unsatisfied_term], cause: cause)
108
+ end
109
+
110
+ # Public: PubGrub Strategy calls this to build version preference index.
111
+ # Returns newest-first so index 0 = newest = most preferred.
112
+ # Locked versions are promoted to the front (most preferred).
113
+ def all_versions_for(package)
114
+ name = package.name
115
+ versions = @provider.versions_for(name).reverse # newest first
116
+
117
+ locked = @locked_specs[name]
118
+ if locked
119
+ locked_ver = Gem::Version.new(locked)
120
+ # Move locked version to front if present
121
+ if (idx = versions.index(locked_ver))
122
+ versions.delete_at(idx)
123
+ versions.unshift(locked_ver)
124
+ end
125
+ end
126
+
127
+ versions
128
+ end
129
+
130
+ private
131
+
132
+ def package_for(name)
133
+ @packages[name] ||= Scint::PubGrub::Package.new(name)
134
+ end
135
+
136
+ def root_dependencies
137
+ deps = {}
138
+ @dependencies.each do |dep|
139
+ pkg = package_for(dep.name)
140
+ req = Gem::Requirement.new(dep.version_reqs)
141
+ range = requirement_to_range(req)
142
+ constraint = Scint::PubGrub::VersionConstraint.new(pkg, range: range)
143
+
144
+ deps[pkg] = if deps[pkg]
145
+ deps[pkg].intersect(constraint)
146
+ else
147
+ constraint
148
+ end
149
+ end
150
+ deps
151
+ end
152
+
153
+ def dependencies_for(package, version)
154
+ name = package.name
155
+ dep_hash = @provider.dependencies_for(name, version)
156
+ result = {}
157
+
158
+ dep_hash.each do |dep_name, dep_req|
159
+ dep_package = package_for(dep_name)
160
+ range = requirement_to_range(dep_req)
161
+ constraint = Scint::PubGrub::VersionConstraint.new(dep_package, range: range)
162
+ result[dep_package] = constraint
163
+ end
164
+
165
+ result
166
+ end
167
+
168
+ def requirement_to_range(requirement)
169
+ ranges = requirement.requirements.map do |(op, version)|
170
+ case op
171
+ when "~>"
172
+ name = "~> #{version}"
173
+ bump = Gem::Version.new(version.bump.to_s + ".A")
174
+ Scint::PubGrub::VersionRange.new(name: name, min: version, max: bump, include_min: true)
175
+ when ">"
176
+ Scint::PubGrub::VersionRange.new(min: version)
177
+ when ">="
178
+ Scint::PubGrub::VersionRange.new(min: version, include_min: true)
179
+ when "<"
180
+ Scint::PubGrub::VersionRange.new(max: version)
181
+ when "<="
182
+ Scint::PubGrub::VersionRange.new(max: version, include_max: true)
183
+ when "="
184
+ Scint::PubGrub::VersionRange.new(min: version, max: version, include_min: true, include_max: true)
185
+ when "!="
186
+ Scint::PubGrub::VersionRange.new(min: version, max: version, include_min: true, include_max: true).invert
187
+ else
188
+ raise ResolveError, "bad version specifier: #{op}"
189
+ end
190
+ end
191
+
192
+ ranges.inject(&:intersect)
193
+ end
194
+
195
+ # Prefetch compact index info for all known gem names.
196
+ def prefetch_all
197
+ names = Set.new
198
+ @dependencies.each { |d| names << d.name }
199
+
200
+ # Also include locked spec names and their transitive deps
201
+ @locked_specs.each_key { |n| names << n }
202
+
203
+ # Fetch versions for each unique client (populates checksums).
204
+ # Skip path/git gems -- they don't use the compact index.
205
+ fetched_clients = Set.new
206
+ names.each do |name|
207
+ next if @provider.path_or_git_gem?(name)
208
+ client = @provider.client_for(name)
209
+ unless fetched_clients.include?(client.source_uri)
210
+ client.fetch_versions
211
+ fetched_clients << client.source_uri
212
+ end
213
+ end
214
+
215
+ # Prefetch all info in parallel (provider routes to correct client)
216
+ @provider.prefetch(names.to_a)
217
+ end
218
+
219
+ def build_resolved_spec(package, version)
220
+ name = package.name
221
+ deps = @provider.dependencies_for(name, version)
222
+ dep_list = deps.map { |n, r| { name: n, version_reqs: [r.to_s] } }
223
+ platform = @provider.preferred_platform_for(name, version)
224
+
225
+ source = @provider.source_uri_for(name)
226
+
227
+ Scint::ResolvedSpec.new(
228
+ name: name,
229
+ version: version.to_s,
230
+ platform: platform,
231
+ dependencies: dep_list,
232
+ source: source,
233
+ has_extensions: (platform == "ruby") && @provider.has_extensions?(name, version),
234
+ remote_uri: nil,
235
+ checksum: nil,
236
+ )
237
+ end
238
+
239
+ # Minimal logger that discards output (PubGrub requires a logger).
240
+ class NullLogger
241
+ def info(&block) = nil
242
+ def debug(&block) = nil
243
+ def warn(&block) = nil
244
+ def error(&block) = nil
245
+ def level=(v); end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "setup"
4
+ require_relative "../fs"
5
+ require "base64"
6
+ require "pathname"
7
+
8
+ module Scint
9
+ module Runtime
10
+ module Exec
11
+ module_function
12
+
13
+ # Execute a command with the bundled environment.
14
+ # Reads Marshal'd runtime config, sets RUBYLIB so the child process
15
+ # has all gem load paths, then Kernel.exec replaces the process.
16
+ #
17
+ # command: the program to run (e.g. "rails")
18
+ # args: array of arguments
19
+ # lock_path: path to .bundle/scint.lock.marshal
20
+ def exec(command, args, lock_path)
21
+ original_env = ENV.to_hash
22
+ lock_data = Setup.load_lock(lock_path)
23
+
24
+ bundle_dir = File.dirname(lock_path)
25
+ scint_lib_dir = File.expand_path("../..", __dir__)
26
+ ruby_dir = File.join(bundle_dir, "ruby",
27
+ RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
28
+
29
+ # Collect all load paths from the runtime config
30
+ paths = []
31
+ lock_data.each_value do |info|
32
+ Array(info[:load_paths]).each do |p|
33
+ paths << p if File.directory?(p)
34
+ end
35
+ end
36
+
37
+ # Ensure our bundler shim wins over global bundler.
38
+ # Order matters: scint lib first, then gem load paths.
39
+ paths.unshift(scint_lib_dir)
40
+
41
+ # Set RUBYLIB so the child process inherits load paths.
42
+ existing = ENV["RUBYLIB"]
43
+ rubylib = paths.join(File::PATH_SEPARATOR)
44
+ rubylib = "#{rubylib}#{File::PATH_SEPARATOR}#{existing}" if existing && !existing.empty?
45
+ ENV["RUBYLIB"] = rubylib
46
+
47
+ ENV["SCINT_RUNTIME_LOCK"] = lock_path
48
+ ENV["GEM_HOME"] = ruby_dir
49
+ ENV["GEM_PATH"] = ruby_dir
50
+ ENV["BUNDLE_PATH"] = bundle_dir
51
+ ENV["BUNDLE_APP_CONFIG"] = bundle_dir
52
+ ENV["BUNDLE_GEMFILE"] = find_gemfile(bundle_dir)
53
+ ENV["PATH"] = prepend_path(File.join(ruby_dir, "bin"), ENV["PATH"])
54
+ ENV["PATH"] = prepend_path(File.join(bundle_dir, "bin"), ENV["PATH"])
55
+ prepend_rubyopt("-rbundler/setup")
56
+ export_original_env(original_env)
57
+
58
+ command = resolve_command(command, bundle_dir, ruby_dir)
59
+
60
+ # Kernel.exec replaces the process
61
+ Kernel.exec(command, *args)
62
+ end
63
+
64
+ def find_gemfile(bundle_dir)
65
+ project_root = File.dirname(bundle_dir)
66
+ gemfile = File.join(project_root, "Gemfile")
67
+ File.exist?(gemfile) ? gemfile : nil
68
+ end
69
+
70
+ def prepend_rubyopt(flag)
71
+ parts = ENV["RUBYOPT"].to_s.split(/\s+/).reject(&:empty?)
72
+ return if parts.include?(flag)
73
+
74
+ ENV["RUBYOPT"] = ([flag] + parts).join(" ")
75
+ end
76
+
77
+ def prepend_path(prefix, current_path)
78
+ return prefix unless current_path && !current_path.empty?
79
+ return current_path if current_path.split(File::PATH_SEPARATOR).include?(prefix)
80
+
81
+ "#{prefix}#{File::PATH_SEPARATOR}#{current_path}"
82
+ end
83
+
84
+ def export_original_env(original_env)
85
+ ENV["SCINT_ORIGINAL_ENV"] ||= Base64.strict_encode64(Marshal.dump(original_env))
86
+ rescue StandardError
87
+ # Non-fatal: shim can fallback to current ENV.
88
+ end
89
+
90
+ def resolve_command(command, bundle_dir, ruby_dir)
91
+ return command if command.include?(File::SEPARATOR)
92
+
93
+ bundle_bin = File.join(bundle_dir, "bin")
94
+ ruby_bin = File.join(ruby_dir, "bin")
95
+ FS.mkdir_p(bundle_bin)
96
+
97
+ bundle_candidate = File.join(bundle_bin, command)
98
+ return bundle_candidate if File.executable?(bundle_candidate)
99
+
100
+ ruby_candidate = File.join(ruby_bin, command)
101
+ return ruby_candidate if File.executable?(ruby_candidate)
102
+
103
+ gem_exec = find_gem_executable(ruby_dir, command)
104
+ return command unless gem_exec
105
+
106
+ write_bundle_exec_wrapper(bundle_candidate, gem_exec, bundle_bin)
107
+ bundle_candidate
108
+ end
109
+
110
+ def find_gem_executable(ruby_dir, command)
111
+ gems_dir = File.join(ruby_dir, "gems")
112
+ return nil unless Dir.exist?(gems_dir)
113
+
114
+ Dir.glob(File.join(gems_dir, "*")).sort.each do |gem_dir|
115
+ %w[exe bin].each do |subdir|
116
+ candidate = File.join(gem_dir, subdir, command)
117
+ return candidate if File.file?(candidate)
118
+ end
119
+ end
120
+
121
+ nil
122
+ end
123
+
124
+ def write_bundle_exec_wrapper(wrapper_path, target_path, bundle_bin)
125
+ relative = Pathname.new(target_path).relative_path_from(Pathname.new(bundle_bin)).to_s
126
+ content = <<~RUBY
127
+ #!/usr/bin/env ruby
128
+ # frozen_string_literal: true
129
+ load File.expand_path("#{relative}", __dir__)
130
+ RUBY
131
+ FS.atomic_write(wrapper_path, content)
132
+ File.chmod(0o755, wrapper_path)
133
+ rescue StandardError
134
+ # If wrapper creation fails, we'll still fall back to PATH lookup.
135
+ end
136
+
137
+ private_class_method :find_gemfile, :prepend_rubyopt, :prepend_path, :export_original_env,
138
+ :resolve_command, :find_gem_executable, :write_bundle_exec_wrapper
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scint
4
+ module Runtime
5
+ module Setup
6
+ module_function
7
+
8
+ # Load and return the Marshal'd lock data without modifying $LOAD_PATH.
9
+ def load_lock(lock_path)
10
+ unless File.exist?(lock_path)
11
+ raise LoadError, "Runtime lock not found: #{lock_path}. Run `scint install` first."
12
+ end
13
+
14
+ Marshal.load(File.binread(lock_path)) # rubocop:disable Security/MarshalLoad
15
+ end
16
+
17
+ # Set up $LOAD_PATH from a Marshal'd lock data file.
18
+ # This is the fast path for in-process setup — <10ms.
19
+ def setup(lock_path)
20
+ lock_data = load_lock(lock_path)
21
+
22
+ paths = []
23
+ lock_data.each_value do |info|
24
+ Array(info[:load_paths]).each do |p|
25
+ paths << p if File.directory?(p)
26
+ end
27
+ end
28
+
29
+ $LOAD_PATH.unshift(*paths)
30
+
31
+ ENV["BUNDLE_GEMFILE"] ||= find_gemfile(File.dirname(lock_path))
32
+
33
+ lock_data
34
+ end
35
+
36
+ def find_gemfile(bundle_dir)
37
+ project_root = File.dirname(bundle_dir)
38
+ gemfile = File.join(project_root, "Gemfile")
39
+ File.exist?(gemfile) ? gemfile : nil
40
+ end
41
+
42
+ private_class_method :find_gemfile
43
+ end
44
+ end
45
+ end