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,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../cache/layout"
5
+ require_relative "../cache/prewarm"
6
+ require_relative "../credentials"
7
+ require_relative "../gemfile/dependency"
8
+ require_relative "../gemfile/parser"
9
+ require_relative "../lockfile/parser"
10
+ require_relative "install"
11
+ require_relative "../fs"
12
+
13
+ module Scint
14
+ module CLI
15
+ class Cache
16
+ def initialize(argv = [])
17
+ @argv = argv.dup
18
+ end
19
+
20
+ def run
21
+ subcommand = @argv.shift || "help"
22
+
23
+ case subcommand
24
+ when "add"
25
+ add
26
+ when "clean", "clear"
27
+ clean
28
+ when "dir", "path"
29
+ dir
30
+ when "size"
31
+ size
32
+ when "help", "-h", "--help"
33
+ help
34
+ 0
35
+ else
36
+ $stderr.puts "Unknown cache subcommand: #{subcommand}"
37
+ $stderr.puts "Run 'scint cache help' for usage."
38
+ 1
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def cache_root
45
+ @cache_root ||= Scint::Cache::Layout.new.root
46
+ end
47
+
48
+ # scint cache clean [package...]
49
+ # With no args, clear everything. With args, clear only matching entries.
50
+ def clean
51
+ packages = @argv.dup
52
+
53
+ unless Dir.exist?(cache_root)
54
+ $stdout.puts "No cache to clear (#{cache_root} does not exist)"
55
+ return 0
56
+ end
57
+
58
+ if packages.empty?
59
+ removed = clear_all
60
+ $stdout.puts "Cleared #{removed} entries from #{cache_root}"
61
+ else
62
+ removed = clear_packages(packages)
63
+ $stdout.puts "Removed #{removed} entries matching: #{packages.join(", ")}"
64
+ end
65
+
66
+ 0
67
+ end
68
+
69
+ # scint cache dir
70
+ def dir
71
+ $stdout.puts cache_root
72
+ 0
73
+ end
74
+
75
+ # scint cache size
76
+ def size
77
+ unless Dir.exist?(cache_root)
78
+ $stdout.puts "0 B (cache is empty)"
79
+ return 0
80
+ end
81
+
82
+ total_bytes = 0
83
+ total_files = 0
84
+ subdirs = {}
85
+
86
+ Dir.children(cache_root).sort.each do |subdir|
87
+ subdir_path = File.join(cache_root, subdir)
88
+ next unless File.directory?(subdir_path)
89
+
90
+ bytes = 0
91
+ files = 0
92
+ Dir.glob("**/*", base: subdir_path).each do |rel|
93
+ full = File.join(subdir_path, rel)
94
+ next unless File.file?(full)
95
+ bytes += File.size(full)
96
+ files += 1
97
+ end
98
+
99
+ subdirs[subdir] = { bytes: bytes, files: files }
100
+ total_bytes += bytes
101
+ total_files += files
102
+ end
103
+
104
+ # Print breakdown with aligned columns
105
+ all_rows = subdirs.map { |name, info| [name, info[:bytes], info[:files]] }
106
+ all_rows << ["total", total_bytes, total_files]
107
+
108
+ nw = all_rows.map { |r| r[0].length }.max
109
+ sw = all_rows.map { |r| format_size(r[1]).length }.max
110
+ fw = all_rows.map { |r| "#{r[2]} files".length }.max
111
+
112
+ all_rows.each do |name, bytes, files|
113
+ $stdout.printf " %-*s %*s %*s\n", nw, name, sw, format_size(bytes), fw, "#{files} files"
114
+ end
115
+
116
+ 0
117
+ end
118
+
119
+ def help
120
+ $stdout.puts <<~HELP
121
+ Manage scint's cache
122
+
123
+ Usage: scint cache <COMMAND>
124
+
125
+ Commands:
126
+ add Prewarm cache from gem names and/or Gemfile/Gemfile.lock
127
+ clean Clear the cache, removing all entries or those linked to specific packages
128
+ dir Show the cache directory
129
+ size Show the cache size
130
+ HELP
131
+ end
132
+
133
+ # scint cache add GEM [GEM...]
134
+ # scint cache add --lockfile Gemfile.lock
135
+ # scint cache add --gemfile Gemfile
136
+ def add
137
+ options = parse_add_options
138
+ if options[:gems].empty? && !options[:lockfile] && !options[:gemfile]
139
+ $stderr.puts "Usage: scint cache add GEM [GEM...] [--lockfile FILE] [--gemfile FILE] [--jobs N] [--force]"
140
+ return 1
141
+ end
142
+
143
+ specs = collect_specs_for_add(options)
144
+ prewarm = Scint::Cache::Prewarm.new(
145
+ cache_layout: Scint::Cache::Layout.new,
146
+ jobs: options[:jobs],
147
+ credentials: options[:credentials],
148
+ force: options[:force],
149
+ )
150
+ result = prewarm.run(specs)
151
+
152
+ if result[:failed] > 0
153
+ $stderr.puts "Cache prewarm failed for #{result[:failed]} gem(s):"
154
+ result[:failures].each do |failure|
155
+ spec = failure[:spec]
156
+ $stderr.puts " #{spec.name}: #{failure[:error].message}"
157
+ end
158
+ $stdout.puts "Cache add: #{result[:warmed]} warmed, #{result[:skipped]} skipped, #{result[:ignored]} ignored."
159
+ return 1
160
+ end
161
+
162
+ $stdout.puts "Cache add complete: #{result[:warmed]} warmed, #{result[:skipped]} skipped, #{result[:ignored]} ignored."
163
+ 0
164
+ end
165
+
166
+ # -- Implementation -------------------------------------------------------
167
+
168
+ def clear_all
169
+ entries = Dir.children(cache_root)
170
+ entries.each do |entry|
171
+ FileUtils.rm_rf(File.join(cache_root, entry))
172
+ end
173
+ entries.size
174
+ end
175
+
176
+ def clear_packages(packages)
177
+ removed = 0
178
+ %w[inbound extracted ext].each do |subdir|
179
+ subdir_path = File.join(cache_root, subdir)
180
+ next unless Dir.exist?(subdir_path)
181
+
182
+ Dir.children(subdir_path).each do |entry|
183
+ if packages.any? { |pkg| entry.start_with?(pkg) }
184
+ FileUtils.rm_rf(File.join(subdir_path, entry))
185
+ removed += 1
186
+ end
187
+ end
188
+ end
189
+ removed
190
+ end
191
+
192
+ def format_size(bytes)
193
+ if bytes < 1024
194
+ "#{bytes} B"
195
+ elsif bytes < 1024 * 1024
196
+ "#{(bytes / 1024.0).round(1)} KiB"
197
+ elsif bytes < 1024 * 1024 * 1024
198
+ "#{(bytes / (1024.0 * 1024)).round(1)} MiB"
199
+ else
200
+ "#{(bytes / (1024.0 * 1024 * 1024)).round(1)} GiB"
201
+ end
202
+ end
203
+
204
+ def parse_add_options
205
+ opts = {
206
+ gems: [],
207
+ lockfile: nil,
208
+ gemfile: nil,
209
+ jobs: nil,
210
+ force: false,
211
+ version: nil,
212
+ source: "https://rubygems.org",
213
+ }
214
+
215
+ i = 0
216
+ while i < @argv.length
217
+ token = @argv[i]
218
+ case token
219
+ when "--lockfile"
220
+ opts[:lockfile] = @argv[i + 1]
221
+ i += 2
222
+ when "--gemfile"
223
+ opts[:gemfile] = @argv[i + 1]
224
+ i += 2
225
+ when "--jobs", "-j"
226
+ opts[:jobs] = @argv[i + 1]&.to_i
227
+ i += 2
228
+ when "--force", "-f"
229
+ opts[:force] = true
230
+ i += 1
231
+ when "--version"
232
+ opts[:version] = @argv[i + 1]
233
+ i += 2
234
+ when "--source"
235
+ opts[:source] = @argv[i + 1]
236
+ i += 2
237
+ else
238
+ if token.start_with?("-")
239
+ raise CacheError, "Unknown option for cache add: #{token}"
240
+ end
241
+ opts[:gems] << token
242
+ i += 1
243
+ end
244
+ end
245
+
246
+ opts[:credentials] = Credentials.new
247
+ opts
248
+ end
249
+
250
+ def collect_specs_for_add(options)
251
+ specs = []
252
+ install = CLI::Install.new([])
253
+ install.instance_variable_set(:@credentials, options[:credentials])
254
+
255
+ if options[:lockfile]
256
+ lockfile = Scint::Lockfile::Parser.parse(options[:lockfile])
257
+ options[:credentials].register_lockfile_sources(lockfile.sources)
258
+ specs.concat(install.send(:lockfile_to_resolved, lockfile))
259
+ end
260
+
261
+ if options[:gemfile]
262
+ specs.concat(resolve_from_gemfile(install, options[:gemfile], options[:credentials]))
263
+ end
264
+
265
+ unless options[:gems].empty?
266
+ specs.concat(resolve_from_names(install, options))
267
+ end
268
+
269
+ dedupe_specs(specs)
270
+ end
271
+
272
+ def resolve_from_gemfile(install, gemfile_path, credentials)
273
+ gemfile = Scint::Gemfile::Parser.parse(gemfile_path)
274
+ credentials.register_sources(gemfile.sources)
275
+ credentials.register_dependencies(gemfile.dependencies)
276
+
277
+ lockfile_path = File.join(File.dirname(File.expand_path(gemfile_path)), "Gemfile.lock")
278
+ lockfile = File.exist?(lockfile_path) ? Scint::Lockfile::Parser.parse(lockfile_path) : nil
279
+ credentials.register_lockfile_sources(lockfile.sources) if lockfile
280
+
281
+ if lockfile && install.send(:lockfile_current?, gemfile, lockfile)
282
+ install.send(:lockfile_to_resolved, lockfile)
283
+ else
284
+ install.send(:resolve, gemfile, lockfile, Scint::Cache::Layout.new)
285
+ end
286
+ end
287
+
288
+ def resolve_from_names(install, options)
289
+ deps = options[:gems].map do |name|
290
+ source_options = {}
291
+ source_options[:source] = options[:source] if options[:source]
292
+ reqs = options[:version] ? [options[:version]] : [">= 0"]
293
+ Scint::Gemfile::Dependency.new(name, version_reqs: reqs, source_options: source_options)
294
+ end
295
+
296
+ gemfile = Scint::Gemfile::ParseResult.new(
297
+ dependencies: deps,
298
+ sources: [{ type: :rubygems, uri: options[:source] }],
299
+ ruby_version: nil,
300
+ platforms: [],
301
+ )
302
+
303
+ install.send(:resolve, gemfile, nil, Scint::Cache::Layout.new)
304
+ end
305
+
306
+ def dedupe_specs(specs)
307
+ seen = {}
308
+ specs.each do |spec|
309
+ key = "#{spec.name}-#{spec.version}-#{spec.platform}"
310
+ seen[key] ||= spec
311
+ end
312
+ seen.values
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../runtime/exec"
4
+ require_relative "../fs"
5
+ require_relative "../platform"
6
+ require_relative "../lockfile/parser"
7
+
8
+ module Scint
9
+ module CLI
10
+ class Exec
11
+ RUNTIME_LOCK = "scint.lock.marshal"
12
+
13
+ def initialize(argv = [])
14
+ @argv = argv
15
+ end
16
+
17
+ def run
18
+ if @argv.empty?
19
+ $stderr.puts "scint exec requires a command to run"
20
+ return 1
21
+ end
22
+
23
+ command = @argv.first
24
+ args = @argv[1..] || []
25
+
26
+ lock_path = find_lock_path
27
+ unless lock_path
28
+ $stderr.puts "No runtime lock found. Run `scint install` first."
29
+ return 1
30
+ end
31
+
32
+ # This calls Kernel.exec and never returns on success
33
+ Runtime::Exec.exec(command, args, lock_path)
34
+ end
35
+
36
+ private
37
+
38
+ def find_lock_path
39
+ # Walk up from cwd looking for .bundle/scint.lock.marshal.
40
+ # If missing but Gemfile.lock + installed gems exist, rebuild it.
41
+ dir = Dir.pwd
42
+ loop do
43
+ bundle_dir = File.join(dir, ".bundle")
44
+ candidate = File.join(dir, ".bundle", RUNTIME_LOCK)
45
+ return candidate if File.exist?(candidate)
46
+
47
+ rebuilt = rebuild_runtime_lock(dir, bundle_dir, candidate)
48
+ return rebuilt if rebuilt
49
+
50
+ parent = File.dirname(dir)
51
+ break if parent == dir # reached root
52
+ dir = parent
53
+ end
54
+
55
+ nil
56
+ end
57
+
58
+ def rebuild_runtime_lock(project_dir, bundle_dir, lock_path)
59
+ return nil unless Dir.exist?(File.join(bundle_dir, "ruby"))
60
+
61
+ gemfile_lock = File.join(project_dir, "Gemfile.lock")
62
+ return nil unless File.exist?(gemfile_lock)
63
+
64
+ ruby_dir = detect_ruby_dir(bundle_dir)
65
+ return nil unless ruby_dir
66
+
67
+ lockfile = Scint::Lockfile::Parser.parse(gemfile_lock)
68
+ data = {}
69
+
70
+ lockfile.specs.each do |spec|
71
+ full = spec_full_name(spec)
72
+ gem_dir = File.join(ruby_dir, "gems", full)
73
+ next unless Dir.exist?(gem_dir)
74
+
75
+ spec_file = File.join(ruby_dir, "specifications", "#{full}.gemspec")
76
+ require_paths = read_require_paths(spec_file)
77
+ load_paths = require_paths
78
+ .map { |rp| File.join(gem_dir, rp) }
79
+ .select { |path| Dir.exist?(path) }
80
+
81
+ lib_path = File.join(gem_dir, "lib")
82
+ load_paths << lib_path if load_paths.empty? && Dir.exist?(lib_path)
83
+ load_paths.concat(detect_nested_lib_paths(gem_dir))
84
+ load_paths.uniq!
85
+
86
+ ext_path = File.join(ruby_dir, "extensions",
87
+ Platform.gem_arch, Platform.extension_api_version, full)
88
+ load_paths << ext_path if Dir.exist?(ext_path)
89
+
90
+ data[spec[:name]] = {
91
+ version: spec[:version].to_s,
92
+ load_paths: load_paths,
93
+ }
94
+ end
95
+
96
+ return nil if data.empty?
97
+
98
+ FS.atomic_write(lock_path, Marshal.dump(data))
99
+ lock_path
100
+ rescue StandardError
101
+ nil
102
+ end
103
+
104
+ def detect_ruby_dir(bundle_dir)
105
+ target = RUBY_VERSION.split(".")[0, 2].join(".") + ".0"
106
+ preferred = File.join(bundle_dir, "ruby", target)
107
+ return preferred if Dir.exist?(preferred)
108
+
109
+ dirs = Dir.glob(File.join(bundle_dir, "ruby", "*")).select { |path| Dir.exist?(path) }
110
+ dirs.sort.first
111
+ end
112
+
113
+ def spec_full_name(spec)
114
+ name = spec[:name]
115
+ version = spec[:version]
116
+ platform = spec[:platform]
117
+ base = "#{name}-#{version}"
118
+ return base if platform.nil? || platform.to_s == "ruby" || platform.to_s.empty?
119
+
120
+ "#{base}-#{platform}"
121
+ end
122
+
123
+ def read_require_paths(spec_file)
124
+ return ["lib"] unless File.exist?(spec_file)
125
+
126
+ gemspec = Gem::Specification.load(spec_file)
127
+ paths = Array(gemspec&.require_paths).reject(&:empty?)
128
+ paths.empty? ? ["lib"] : paths
129
+ rescue StandardError
130
+ ["lib"]
131
+ end
132
+
133
+ def detect_nested_lib_paths(gem_dir)
134
+ lib_dir = File.join(gem_dir, "lib")
135
+ return [] unless Dir.exist?(lib_dir)
136
+
137
+ children = Dir.children(lib_dir)
138
+ top_level_rb = children.any? do |entry|
139
+ path = File.join(lib_dir, entry)
140
+ File.file?(path) && entry.end_with?(".rb")
141
+ end
142
+ return [] if top_level_rb
143
+
144
+ children
145
+ .map { |entry| File.join(lib_dir, entry) }
146
+ .select { |path| File.directory?(path) }
147
+ end
148
+ end
149
+ end
150
+ end