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,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dependency"
4
+
5
+ module Scint
6
+ module Gemfile
7
+ # Result of parsing a Gemfile.
8
+ ParseResult = Struct.new(:dependencies, :sources, :ruby_version, :platforms, keyword_init: true)
9
+
10
+ # Evaluates a Gemfile using instance_eval, just like stock bundler.
11
+ # Supports the full Gemfile DSL: source, gem, group, platforms, git_source,
12
+ # git, path, eval_gemfile, ruby, gemspec, and user-defined methods.
13
+ class Parser
14
+ def self.parse(gemfile_path)
15
+ parser = new(gemfile_path)
16
+ parser.evaluate
17
+ ParseResult.new(
18
+ dependencies: parser.parsed_dependencies,
19
+ sources: parser.parsed_sources.uniq,
20
+ ruby_version: parser.parsed_ruby_version,
21
+ platforms: parser.parsed_platforms,
22
+ )
23
+ end
24
+
25
+ # Accessors that don't collide with DSL method names
26
+ def parsed_dependencies; @dependencies; end
27
+ def parsed_sources; @sources; end
28
+ def parsed_ruby_version; @ruby_version; end
29
+ def parsed_platforms; @declared_platforms; end
30
+
31
+ def initialize(gemfile_path)
32
+ @gemfile_path = File.expand_path(gemfile_path)
33
+ @dependencies = []
34
+ @sources = []
35
+ @git_sources = {}
36
+ @current_groups = []
37
+ @current_platforms = []
38
+ @current_source_options = {}
39
+ @ruby_version = nil
40
+ @declared_platforms = []
41
+
42
+ add_default_git_sources
43
+ end
44
+
45
+ def evaluate
46
+ contents = File.read(@gemfile_path)
47
+ instance_eval(contents, @gemfile_path, 1)
48
+ rescue SyntaxError => e
49
+ raise GemfileError, "Syntax error in #{File.basename(@gemfile_path)}: #{e.message}"
50
+ rescue ScriptError, StandardError => e
51
+ raise GemfileError, "Error evaluating #{File.basename(@gemfile_path)}: #{e.message}"
52
+ end
53
+
54
+ # --- Gemfile DSL methods ---
55
+
56
+ def source(url, &blk)
57
+ url = url.to_s
58
+ if block_given?
59
+ old_source = @current_source_options.dup
60
+ @current_source_options = { source: url }
61
+ @sources << { type: :rubygems, uri: url }
62
+ yield
63
+ @current_source_options = old_source
64
+ else
65
+ @sources << { type: :rubygems, uri: url }
66
+ end
67
+ end
68
+
69
+ def gem(name, *args)
70
+ options = args.last.is_a?(Hash) ? args.pop.dup : {}
71
+ version_reqs = args.flatten
72
+
73
+ # Collect groups
74
+ groups = @current_groups.dup
75
+ if options[:group] || options[:groups]
76
+ extra = Array(options.delete(:group)) + Array(options.delete(:groups))
77
+ groups.concat(extra.map(&:to_sym))
78
+ end
79
+ groups = [:default] if groups.empty?
80
+
81
+ # Collect platforms
82
+ plats = @current_platforms.dup
83
+ if options[:platform] || options[:platforms]
84
+ extra = Array(options.delete(:platform)) + Array(options.delete(:platforms))
85
+ plats.concat(extra.map(&:to_sym))
86
+ end
87
+
88
+ # Handle require paths
89
+ require_paths = nil
90
+ if options.key?(:require)
91
+ req = options.delete(:require)
92
+ require_paths = req == false ? [] : Array(req)
93
+ end
94
+
95
+ # Build source options from git_source helpers and explicit options
96
+ source_opts = @current_source_options.dup
97
+
98
+ # Handle custom git sources (e.g. shopify: "repo-name")
99
+ @git_sources.each do |src_name, block|
100
+ if options.key?(src_name)
101
+ repo = options.delete(src_name)
102
+ result = block.call(repo)
103
+ if result.is_a?(Hash)
104
+ source_opts.merge!(result.transform_keys(&:to_sym))
105
+ else
106
+ source_opts[:git] = result.to_s
107
+ end
108
+ end
109
+ end
110
+
111
+ # Handle explicit git/github/path/source options
112
+ if options[:github]
113
+ repo = options.delete(:github)
114
+ # Handle pull request URLs
115
+ if repo =~ %r{\Ahttps://github\.com/([^/]+/[^/]+)/pull/(\d+)\z}
116
+ source_opts[:git] = "https://github.com/#{$1}.git"
117
+ source_opts[:ref] = "refs/pull/#{$2}/head"
118
+ else
119
+ repo = "#{repo}/#{repo}" unless repo.include?("/")
120
+ source_opts[:git] = "https://github.com/#{repo}.git"
121
+ end
122
+ end
123
+
124
+ if options[:git]
125
+ source_opts[:git] = options.delete(:git)
126
+ end
127
+
128
+ if options[:path]
129
+ path_val = options.delete(:path)
130
+ # Resolve relative paths against the Gemfile's directory
131
+ unless path_val.start_with?("/")
132
+ path_val = File.expand_path(path_val, File.dirname(@gemfile_path))
133
+ end
134
+ source_opts[:path] = path_val
135
+ end
136
+
137
+ if options[:source]
138
+ source_opts[:source] = options.delete(:source)
139
+ end
140
+
141
+ # Copy over git-related options
142
+ [:branch, :ref, :tag, :submodules].each do |key|
143
+ source_opts[key] = options.delete(key) if options.key?(key)
144
+ end
145
+
146
+ # Ignore options we don't use but shouldn't error on
147
+ options.delete(:force_ruby_platform)
148
+ options.delete(:install_if)
149
+
150
+ dep = Dependency.new(
151
+ name,
152
+ version_reqs: version_reqs,
153
+ groups: groups,
154
+ platforms: plats,
155
+ require_paths: require_paths,
156
+ source_options: source_opts,
157
+ )
158
+
159
+ @dependencies << dep
160
+ end
161
+
162
+ def group(*names, **opts, &blk)
163
+ old_groups = @current_groups.dup
164
+ @current_groups.concat(names.map(&:to_sym))
165
+ yield
166
+ ensure
167
+ @current_groups = old_groups
168
+ end
169
+
170
+ def platforms(*names, &blk)
171
+ old_platforms = @current_platforms.dup
172
+ @current_platforms.concat(names.map(&:to_sym))
173
+ yield
174
+ ensure
175
+ @current_platforms = old_platforms
176
+ end
177
+ alias_method :platform, :platforms
178
+
179
+ def git_source(name, &block)
180
+ raise GemfileError, "git_source requires a block" unless block_given?
181
+ @git_sources[name.to_sym] = block
182
+ end
183
+
184
+ def git(url, **opts, &blk)
185
+ raise GemfileError, "git requires a block" unless block_given?
186
+ old_source = @current_source_options.dup
187
+ @current_source_options = { git: url }.merge(opts)
188
+ yield
189
+ ensure
190
+ @current_source_options = old_source
191
+ end
192
+
193
+ def path(path_str, **opts, &blk)
194
+ old_source = @current_source_options.dup
195
+ resolved = if path_str.start_with?("/")
196
+ path_str
197
+ else
198
+ File.expand_path(path_str, File.dirname(@gemfile_path))
199
+ end
200
+ @current_source_options = { path: resolved }.merge(opts)
201
+ yield if block_given?
202
+ ensure
203
+ @current_source_options = old_source
204
+ end
205
+
206
+ def eval_gemfile(path)
207
+ expanded = if path.start_with?("/")
208
+ path
209
+ else
210
+ File.expand_path(path, File.dirname(@gemfile_path))
211
+ end
212
+ contents = File.read(expanded)
213
+ instance_eval(contents, expanded, 1)
214
+ end
215
+
216
+ def ruby(version, **opts)
217
+ @ruby_version = version.to_s
218
+ end
219
+
220
+ def gemspec(opts = {})
221
+ path = opts[:path] || "."
222
+ name = opts[:name]
223
+ dir = File.expand_path(path, File.dirname(@gemfile_path))
224
+ gemspecs = Dir.glob(File.join(dir, "{,*}.gemspec"))
225
+ # Just record we have a gemspec source -- full spec loading is
226
+ # deferred to the resolver/installer.
227
+ gemspecs.each do |gs|
228
+ spec_name = File.basename(gs, ".gemspec")
229
+ next if name && spec_name != name
230
+ gem(spec_name, path: dir)
231
+ end
232
+ end
233
+
234
+ # Silently ignore plugin declarations
235
+ def plugin(*args); end
236
+
237
+ # Allow user-defined methods (like `in_repo_gem`) and unknown DSL
238
+ # methods to raise a clear error.
239
+ def method_missing(name, *args, &block)
240
+ raise GemfileError, "Undefined local variable or method `#{name}' for Gemfile\n" \
241
+ " in #{@gemfile_path}"
242
+ end
243
+
244
+ def respond_to_missing?(name, include_private = false)
245
+ false
246
+ end
247
+
248
+ private
249
+
250
+ def add_default_git_sources
251
+ git_source(:github) do |repo_name|
252
+ if repo_name =~ %r{\Ahttps://github\.com/([^/]+/[^/]+)/pull/(\d+)\z}
253
+ {
254
+ git: "https://github.com/#{$1}.git",
255
+ ref: "refs/pull/#{$2}/head",
256
+ }
257
+ else
258
+ repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
259
+ "https://github.com/#{repo_name}.git"
260
+ end
261
+ end
262
+
263
+ git_source(:gist) do |repo_name|
264
+ "https://gist.github.com/#{repo_name}.git"
265
+ end
266
+
267
+ git_source(:bitbucket) do |repo_name|
268
+ user, repo = repo_name.split("/")
269
+ repo ||= user
270
+ "https://#{user}@bitbucket.org/#{user}/#{repo}.git"
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest"
5
+
6
+ module Scint
7
+ module Index
8
+ class Cache
9
+ attr_reader :directory
10
+
11
+ def initialize(cache_dir)
12
+ @directory = File.expand_path(cache_dir)
13
+ @info_dir = File.join(@directory, "info")
14
+ @info_etag_dir = File.join(@directory, "info-etags")
15
+ @info_binary_dir = File.join(@directory, "info-binary")
16
+ @mutex = Thread::Mutex.new
17
+
18
+ ensure_dirs
19
+ end
20
+
21
+ # Source slug for cache directory naming.
22
+ # e.g. "rubygems.org" or "gems.example.com-private"
23
+ def self.slug_for(uri)
24
+ uri = URI.parse(uri.to_s) unless uri.is_a?(URI)
25
+ path = uri.path.to_s.gsub("/", "-").sub(/^-/, "")
26
+ slug = uri.host.to_s
27
+ slug += path unless path.empty? || path == "-"
28
+ slug
29
+ end
30
+
31
+ # Read cached names file.
32
+ def names
33
+ read_file(names_path)
34
+ end
35
+
36
+ # Write names file.
37
+ def write_names(data, etag: nil)
38
+ write_file(names_path, data)
39
+ write_file(names_etag_path, etag) if etag
40
+ end
41
+
42
+ # Read the ETag for names.
43
+ def names_etag
44
+ read_file(names_etag_path)&.chomp
45
+ end
46
+
47
+ # Read cached versions file.
48
+ def versions
49
+ read_file(versions_path)
50
+ end
51
+
52
+ # Write versions file. Supports appending for range requests.
53
+ def write_versions(data, etag: nil, append: false)
54
+ if append && File.exist?(versions_path)
55
+ File.open(versions_path, "ab") { |f| f.write(data) }
56
+ else
57
+ write_file(versions_path, data)
58
+ end
59
+ write_file(versions_etag_path, etag) if etag
60
+ end
61
+
62
+ # Read the ETag for versions.
63
+ def versions_etag
64
+ read_file(versions_etag_path)&.chomp
65
+ end
66
+
67
+ # Size of the versions file (for Range requests).
68
+ def versions_size
69
+ File.exist?(versions_path) ? File.size(versions_path) : 0
70
+ end
71
+
72
+ # Read cached info for a gem.
73
+ def info(name)
74
+ read_file(info_path(name))
75
+ end
76
+
77
+ # Write info for a gem.
78
+ def write_info(name, data, etag: nil)
79
+ write_file(info_path(name), data)
80
+ write_file(info_etag_path(name), etag) if etag
81
+ end
82
+
83
+ # Read the ETag for a gem's info.
84
+ def info_etag(name)
85
+ read_file(info_etag_path(name))&.chomp
86
+ end
87
+
88
+ # Check local checksum of info file against remote.
89
+ def info_fresh?(name, remote_checksum)
90
+ return false unless remote_checksum && !remote_checksum.empty?
91
+ data = info(name)
92
+ return false unless data
93
+ local_checksum = Digest::MD5.hexdigest(data)
94
+ local_checksum == remote_checksum
95
+ end
96
+
97
+ # Read binary (Marshal) cached parsed info. Returns nil if missing/stale.
98
+ def read_binary_info(name, expected_checksum)
99
+ path = info_binary_path(name)
100
+ return nil unless File.exist?(path)
101
+
102
+ cached = Marshal.load(File.binread(path)) # rubocop:disable Security/MarshalLoad
103
+ if cached.is_a?(Array) && cached.length == 2 && cached[0] == expected_checksum
104
+ cached[1]
105
+ end
106
+ rescue StandardError
107
+ nil
108
+ end
109
+
110
+ # Write binary (Marshal) cached parsed info.
111
+ def write_binary_info(name, checksum, parsed_data)
112
+ path = info_binary_path(name)
113
+ FS.mkdir_p(File.dirname(path))
114
+ FS.atomic_write(path, Marshal.dump([checksum, parsed_data]))
115
+ rescue StandardError
116
+ # Non-fatal
117
+ end
118
+
119
+ private
120
+
121
+ def names_path = File.join(@directory, "names")
122
+ def names_etag_path = File.join(@directory, "names.etag")
123
+ def versions_path = File.join(@directory, "versions")
124
+ def versions_etag_path = File.join(@directory, "versions.etag")
125
+
126
+ def info_path(name)
127
+ name = name.to_s
128
+ if /[^a-z0-9_-]/.match?(name)
129
+ File.join(@info_dir, "#{name}-#{hex(name)}")
130
+ else
131
+ File.join(@info_dir, name)
132
+ end
133
+ end
134
+
135
+ def info_etag_path(name)
136
+ name = name.to_s
137
+ File.join(@info_etag_dir, "#{name}-#{hex(name)}")
138
+ end
139
+
140
+ def info_binary_path(name)
141
+ File.join(@info_binary_dir, "#{name}.bin")
142
+ end
143
+
144
+ def hex(str)
145
+ Digest::MD5.hexdigest(str)[0, 12]
146
+ end
147
+
148
+ def ensure_dirs
149
+ [@directory, @info_dir, @info_etag_dir, @info_binary_dir].each do |dir|
150
+ FS.mkdir_p(dir)
151
+ end
152
+ end
153
+
154
+ def read_file(path)
155
+ return nil unless File.exist?(path)
156
+ File.read(path)
157
+ end
158
+
159
+ def write_file(path, data)
160
+ return if data.nil?
161
+ FS.mkdir_p(File.dirname(path))
162
+ FS.atomic_write(path, data)
163
+ end
164
+ end
165
+ end
166
+ end