scint 0.6.0 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1c0c2f09127da28473aad491848c5b3e7b976fa41405a94ce542859fa1aabf8
4
- data.tar.gz: 2e7b1801b9a3004bc0d9709de9502bfff5a8b475178aa102ebb730a6cb1b6344
3
+ metadata.gz: 54c3f7baaea3992bcba60136b76f3b08caa580717d82906ebed3a0a69fb23314
4
+ data.tar.gz: 246966d0ba167f43b8c0aee1205e95377afec245dca33060fe3fcce202d0c3ab
5
5
  SHA512:
6
- metadata.gz: 3d95e5006966b2025c5e21951bc918849081c330a2872e7e665cafa6d73ab522871f7e41aa2ed4c61bbed37e73363f19650dce16bbc1e472b3e4fe54c0177d97
7
- data.tar.gz: 40fba5802be2359b85d201c2c732f8edac15f1e1be7ee17b2d59a39de9ac2f1ca6befd14d527fcacb4a5e763408c1296abe35301f8224a4b1138a5a4a1706096
6
+ metadata.gz: 84c58296b1c391e68503090ea3889a3a469c8f63ca6c289710dc6a5f986cd33059aec6a61038ab14c2f653aefaa1675815d2e13a5ba3fb49dad6fe825cf8d3a5
7
+ data.tar.gz: 1cde1ae7ff37856bf44a4f909e024cb094ba1436fe062536aa4c73860ce0aa7c2494c2f5dd09ca14af5be8b226d8ada36ec212278b08a55902da708d734bea3d
data/README.md CHANGED
@@ -37,6 +37,29 @@ scint cache clear
37
37
  scint cache dir
38
38
  ```
39
39
 
40
+ Benchmark helpers:
41
+
42
+ ```bash
43
+ bin/scint-vs-bundler [--force] [--test-root /tmp/scint-tests] /path/to/project
44
+ bin/scint-bench-matrix [--force] --root /path/to/projects
45
+ ```
46
+
47
+ `bin/scint-bench-matrix` is a generic runner for a root directory where each
48
+ immediate subdirectory is a Ruby project under git with both `Gemfile` and
49
+ `Gemfile.lock`. It runs bundler cold/warm and scint cold/warm via
50
+ `bin/scint-vs-bundler` and writes:
51
+
52
+ 1. `logs/bench-<timestamp>/summary.tsv`
53
+ 2. `logs/bench-<timestamp>/table.md`
54
+
55
+ Optional project smoke test convention:
56
+
57
+ 1. If `<root>/<project>-test.sh` exists, matrix runs it after the benchmark.
58
+ 2. Execution is:
59
+ `cd <root>/<project> && scint exec ../<project>-test.sh`
60
+ 3. The script runs against the warm scint install and is included in
61
+ `summary.tsv`/`table.md` status.
62
+
40
63
  Performance and IO diagnostics:
41
64
 
42
65
  ```bash
@@ -64,45 +87,45 @@ Defaults:
64
87
  1. Local install/runtime directory: `.bundle/`
65
88
  2. Global cache root: `~/.cache/scint` (or `XDG_CACHE_HOME`)
66
89
 
67
- ## Install Architecture
90
+ ## Install Architecture (Target)
91
+
92
+ Scint should have one clear cache lifecycle:
68
93
 
69
- Scint install is phase-oriented. Each phase has explicit responsibilities and feeds the next phase.
94
+ 1. `inbound`
95
+ 2. `assembling`
96
+ 3. `cached`
97
+ 4. `materialize`
70
98
 
71
- 1. Parse inputs (`Gemfile`, optional `Gemfile.lock`)
72
- 2. Fetch source metadata (indexes, git clones)
73
- 3. Resolve dependency graph
74
- 4. Plan actions (`skip`, `link`, `download`, `build_ext`)
75
- 5. Download/extract/cache artifacts
76
- 6. Link into local `.bundle` runtime
77
- 7. Build native extensions after link phase is ready
78
- 8. Write outputs (`Gemfile.lock`, runtime lock, warnings/summary)
99
+ Resolution/planning still decides *what* to install; this pipeline defines *how* each artifact becomes globally reusable.
100
+
101
+ ### Phase Contract
102
+
103
+ 1. Fetch into `inbound`
104
+ - Gem payloads go to `inbound/gems/`.
105
+ - Git repositories go to `inbound/gits/` using deterministic names (for example `https_github_com__tobi__try`).
106
+ 2. Assemble into `assembling`
107
+ - For `.gem` sources: unpack into `assembling/<abi>/<full_name>/`.
108
+ - For git sources: fetch/checkout/submodules in `inbound/gits`, then export/copy the selected tree into `assembling/<abi>/<full_name>/`.
109
+ 3. Compile in `assembling`
110
+ - Native extension build happens inside the assembling directory so successful outputs are part of the final cached tree.
111
+ 4. Promote atomically to `cached`
112
+ - On success, move `assembling/<abi>/<full_name>/` to `cached/<abi>/<full_name>/`.
113
+ - Write `cached/<abi>/<full_name>.spec.marshal`.
114
+ - Write optional manifest metadata for fast materialization.
115
+ 5. Materialize to project path (`.bundle` or `BUNDLE_PATH`)
116
+ - Use clonefile/reflink/hardlink/copy fallback from `cached/<abi>/...`.
117
+ - Do not rebuild if cached artifact is already complete.
118
+
119
+ This gives one primary truth source for warm installs: `cached/<abi>`.
79
120
 
80
121
  ```mermaid
81
122
  flowchart LR
82
- A[Gemfile + Gemfile.lock] --> B[Parse + Source Discovery]
83
- B --> C[Fetch Indexes / Clone Git]
84
- C --> D[Resolve Graph]
85
- D --> E[Planner]
86
- E -->|skip| F[Already Installed]
87
- E -->|link| G[Link from Extracted Cache]
88
- E -->|download| H[Download .gem]
89
- H --> I[Extract + Cache Metadata]
90
- I --> G
91
- G --> J[All Links Complete]
92
- J --> K[Native Extension Build]
93
- K --> L[Runtime + Lockfile Write]
94
- L --> M[Done]
95
-
96
- subgraph GlobalCache["Global Cache (~/.cache/scint)"]
97
- H
98
- I
99
- end
100
-
101
- subgraph ProjectRuntime["Project Runtime (.bundle)"]
102
- G
103
- K
104
- L
105
- end
123
+ A[Resolve + Plan] --> B[Fetch to inbound]
124
+ B --> C[Assemble in assembling]
125
+ C --> D[Compile in assembling]
126
+ D --> E[Promote to cached]
127
+ E --> F[Materialize to .bundle]
128
+ F --> G[Write Runtime + Lockfile]
106
129
  ```
107
130
 
108
131
  ## Scheduler as Session Object
@@ -154,24 +177,50 @@ stateDiagram-v2
154
177
  failed --> [*]
155
178
  ```
156
179
 
157
- ## Data Layout
180
+ ## Data Layout (Target)
158
181
 
159
182
  Global cache (`~/.cache/scint`):
160
183
 
161
- 1. `inbound/` downloaded gem files
162
- 2. `extracted/` unpacked gem trees
163
- 3. `ext/` compiled extension cache keyed by ABI
164
- 4. `index/` source metadata/index cache
165
- 5. `git/` cached git repositories
184
+ ```text
185
+ ~/.cache/scint/
186
+ inbound/
187
+ gems/
188
+ <full_name>.gem
189
+ gits/
190
+ <deterministic_repo_slug>/
191
+ assembling/
192
+ <ruby-abi>/
193
+ <full_name>/
194
+ cached/
195
+ <ruby-abi>/
196
+ <full_name>/
197
+ <full_name>.spec.marshal
198
+ <full_name>.manifest
199
+ index/
200
+ ```
201
+
202
+ Example ABI key and gem directory:
203
+
204
+ 1. `cached/ruby-3.4.5-arm64-darwin24/zlib-3.2.1/`
205
+ 2. `cached/ruby-3.4.5-arm64-darwin24/zlib-3.2.1.spec.marshal`
166
206
 
167
207
  Project-local runtime (`.bundle/`):
168
208
 
169
- 1. `ruby/<major.minor.0>/gems/` linked gem trees
209
+ 1. `ruby/<major.minor.0>/gems/` materialized gem trees
170
210
  2. `ruby/<major.minor.0>/specifications/` gemspecs
171
211
  3. `ruby/<major.minor.0>/bin/` gem binstubs
172
212
  4. `bin/` project-level wrappers
173
213
  5. `scint.lock.marshal` runtime lock for `scint exec`
174
214
 
215
+ ## Warm Path Guarantees
216
+
217
+ Required behavior:
218
+
219
+ 1. If `cached/<abi>/<full_name>/` exists and is valid, no fetch/extract/compile occurs for that gem.
220
+ 2. Deleting only `.bundle/` should trigger only materialization work.
221
+ 3. Materialization should be IO-bound and close to instantaneous on warm cache.
222
+ 4. Incomplete assemblies must never be promoted; promotion is atomic.
223
+
175
224
  ## Concurrency Model
176
225
 
177
226
  Scint parallelizes all non-conflicting work aggressively:
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.0
1
+ 0.7.0
data/bin/scint CHANGED
@@ -1,6 +1,15 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # Enable YJIT as early as possible when available.
5
+ if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
6
+ begin
7
+ RubyVM::YJIT.enable unless RubyVM::YJIT.enabled?
8
+ rescue StandardError
9
+ # Keep startup resilient when YJIT is unavailable or disabled at runtime.
10
+ end
11
+ end
12
+
4
13
  # Ensure lib is on the load path
5
14
  $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
6
15
 
data/lib/bundler.rb CHANGED
@@ -100,10 +100,14 @@ module Bundler
100
100
  Kernel.require(candidate)
101
101
  return true
102
102
  rescue LoadError => e
103
+ raise e unless retryable_load_error?(e, candidate)
104
+
103
105
  last_error = e
104
106
  end
105
107
  end
106
108
 
109
+ return true if require_matching_basename(name)
110
+
107
111
  raise last_error if last_error
108
112
  end
109
113
 
@@ -164,5 +168,107 @@ module Bundler
164
168
  end
165
169
  filtered.join(" ")
166
170
  end
171
+
172
+ def require_matching_basename(name)
173
+ target = normalize_basename(name)
174
+ return false if target.empty?
175
+
176
+ exact_candidates = []
177
+ fuzzy_candidates = []
178
+
179
+ $LOAD_PATH.each do |load_dir|
180
+ next unless File.directory?(load_dir)
181
+
182
+ Dir.children(load_dir).sort.each do |entry|
183
+ next unless entry.end_with?(".rb")
184
+
185
+ basename = entry.delete_suffix(".rb")
186
+ normalized = normalize_basename(basename)
187
+ if normalized == target
188
+ exact_candidates << basename
189
+ elsif compatible_basename?(target, normalized)
190
+ fuzzy_candidates << [basename, normalized]
191
+ end
192
+ end
193
+ rescue StandardError
194
+ next
195
+ end
196
+
197
+ exact_candidates.uniq.each do |candidate|
198
+ begin
199
+ Kernel.require(candidate)
200
+ return true
201
+ rescue LoadError => e
202
+ raise e unless retryable_load_error?(e, candidate)
203
+
204
+ next
205
+ end
206
+ end
207
+
208
+ fuzzy_candidates
209
+ .uniq
210
+ .sort_by { |_basename, normalized| [((target.length - normalized.length).abs), -normalized.length] }
211
+ .map(&:first)
212
+ .each do |candidate|
213
+ begin
214
+ Kernel.require(candidate)
215
+ return true
216
+ rescue LoadError => e
217
+ raise e unless retryable_load_error?(e, candidate)
218
+
219
+ next
220
+ end
221
+ end
222
+
223
+ false
224
+ end
225
+
226
+ def normalize_basename(name)
227
+ name.to_s.downcase.gsub(/[^a-z0-9]/, "")
228
+ end
229
+
230
+ def retryable_load_error?(error, candidate)
231
+ requested = if error.respond_to?(:path)
232
+ error.path
233
+ else
234
+ nil
235
+ end
236
+ return true if requested.nil? || requested.empty?
237
+
238
+ variants = [
239
+ candidate.to_s,
240
+ "#{candidate}.rb",
241
+ "#{candidate}.so",
242
+ ]
243
+ variants.include?(requested)
244
+ end
245
+
246
+ def compatible_basename?(target, normalized)
247
+ return false if target.length < 3 || normalized.length < 3
248
+
249
+ target_variants = basename_variants(target)
250
+ normalized_variants = basename_variants(normalized)
251
+ return true if (target_variants & normalized_variants).any?
252
+
253
+ target_variants.any? do |t|
254
+ normalized_variants.any? { |n| t.start_with?(n) || n.start_with?(t) }
255
+ end
256
+ end
257
+
258
+ def basename_variants(name)
259
+ value = name.to_s.downcase
260
+ variants = [value]
261
+
262
+ plural_to_s = value.sub(/ties\z/, "s")
263
+ plural_to_y = value.sub(/ies\z/, "y")
264
+ singular = value.sub(/s\z/, "")
265
+ strip_ruby_prefix = value.sub(/\Aruby/, "")
266
+
267
+ variants << plural_to_s unless plural_to_s == value
268
+ variants << plural_to_y unless plural_to_y == value
269
+ variants << singular unless singular == value
270
+ variants << strip_ruby_prefix unless strip_ruby_prefix == value
271
+ variants.uniq.select { |v| v.length >= 3 }
272
+ end
167
273
  end
168
274
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../fs"
4
4
  require_relative "../platform"
5
+ require_relative "../spec_utils"
5
6
  require "digest"
6
7
  require "uri"
7
8
 
@@ -35,7 +36,7 @@ module Scint
35
36
  end
36
37
 
37
38
  def git_dir
38
- File.join(@root, "git")
39
+ File.join(inbound_dir, "git")
39
40
  end
40
41
 
41
42
  # Isolated gem home used while compiling native extensions during install.
@@ -45,7 +46,7 @@ module Scint
45
46
  end
46
47
 
47
48
  def install_ruby_dir
48
- File.join(install_env_dir, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
49
+ Platform.ruby_install_dir(install_env_dir)
49
50
  end
50
51
 
51
52
  # -- Per-spec paths ------------------------------------------------------
@@ -78,23 +79,20 @@ module Scint
78
79
  end
79
80
 
80
81
  def git_path(uri)
81
- slug = Digest::SHA256.hexdigest(uri.to_s)[0, 16]
82
- File.join(git_dir, slug)
82
+ slug = git_slug(uri)
83
+ File.join(git_dir, "repos", slug)
84
+ end
85
+
86
+ def git_checkout_path(uri, revision)
87
+ slug = git_slug(uri)
88
+ rev = revision.to_s.gsub(/[^0-9A-Za-z._-]/, "_")
89
+ File.join(git_dir, "checkouts", slug, rev)
83
90
  end
84
91
 
85
92
  # -- Helpers -------------------------------------------------------------
86
93
 
87
94
  def full_name(spec)
88
- name = spec.respond_to?(:name) ? spec.name : spec[:name]
89
- version = spec.respond_to?(:version) ? spec.version : spec[:version]
90
- platform = spec.respond_to?(:platform) ? spec.platform : spec[:platform]
91
-
92
- base = "#{name}-#{version}"
93
- if platform && platform.to_s != "ruby" && platform.to_s != ""
94
- "#{base}-#{platform}"
95
- else
96
- base
97
- end
95
+ SpecUtils.full_name(spec)
98
96
  end
99
97
 
100
98
  # Ensure a directory exists (thread-safe, cached).
@@ -126,6 +124,10 @@ module Scint
126
124
  Digest::SHA256.hexdigest(str)[0, 16]
127
125
  end
128
126
  end
127
+
128
+ def git_slug(uri)
129
+ Digest::SHA256.hexdigest(uri.to_s)[0, 16]
130
+ end
129
131
  end
130
132
  end
131
133
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../fs"
4
+ require_relative "../spec_utils"
4
5
 
5
6
  module Scint
6
7
  module Cache
@@ -30,7 +31,7 @@ module Scint
30
31
  # Check if a gem is installed. specs_hash keys are "name-version" or "name-version-platform".
31
32
  def installed?(name, version, platform = "ruby")
32
33
  data = load
33
- key = cache_key(name, version, platform)
34
+ key = SpecUtils.full_name_for(name, version, platform)
34
35
  data.key?(key)
35
36
  end
36
37
 
@@ -38,7 +39,7 @@ module Scint
38
39
  def add(name, version, platform = "ruby")
39
40
  @mutex.synchronize do
40
41
  @data ||= load_from_disk
41
- key = cache_key(name, version, platform)
42
+ key = SpecUtils.full_name_for(name, version, platform)
42
43
  @data[key] = true
43
44
  FS.atomic_write(@path, Marshal.dump(@data))
44
45
  end
@@ -48,7 +49,7 @@ module Scint
48
49
  def remove(name, version, platform = "ruby")
49
50
  @mutex.synchronize do
50
51
  @data ||= load_from_disk
51
- key = cache_key(name, version, platform)
52
+ key = SpecUtils.full_name_for(name, version, platform)
52
53
  @data.delete(key)
53
54
  FS.atomic_write(@path, Marshal.dump(@data))
54
55
  end
@@ -56,14 +57,6 @@ module Scint
56
57
 
57
58
  private
58
59
 
59
- def cache_key(name, version, platform)
60
- if platform && platform.to_s != "ruby" && platform.to_s != ""
61
- "#{name}-#{version}-#{platform}"
62
- else
63
- "#{name}-#{version}"
64
- end
65
- end
66
-
67
60
  def load_from_disk
68
61
  return {} unless File.exist?(@path)
69
62
  Marshal.load(File.binread(@path))
@@ -9,6 +9,7 @@ require_relative "../gemfile/parser"
9
9
  require_relative "../lockfile/parser"
10
10
  require_relative "install"
11
11
  require_relative "../fs"
12
+ require_relative "../spec_utils"
12
13
 
13
14
  module Scint
14
15
  module CLI
@@ -306,7 +307,7 @@ module Scint
306
307
  def dedupe_specs(specs)
307
308
  seen = {}
308
309
  specs.each do |spec|
309
- key = "#{spec.name}-#{spec.version}-#{spec.platform}"
310
+ key = SpecUtils.full_key(spec)
310
311
  seen[key] ||= spec
311
312
  end
312
313
  seen.values
@@ -3,7 +3,9 @@
3
3
  require_relative "../runtime/exec"
4
4
  require_relative "../fs"
5
5
  require_relative "../platform"
6
+ require_relative "../spec_utils"
6
7
  require_relative "../lockfile/parser"
8
+ require "pathname"
7
9
 
8
10
  module Scint
9
11
  module CLI
@@ -68,19 +70,18 @@ module Scint
68
70
  data = {}
69
71
 
70
72
  lockfile.specs.each do |spec|
71
- full = spec_full_name(spec)
73
+ full = SpecUtils.full_name(spec)
72
74
  gem_dir = File.join(ruby_dir, "gems", full)
73
75
  next unless Dir.exist?(gem_dir)
74
76
 
75
77
  spec_file = File.join(ruby_dir, "specifications", "#{full}.gemspec")
76
78
  require_paths = read_require_paths(spec_file)
77
79
  load_paths = require_paths
78
- .map { |rp| File.join(gem_dir, rp) }
80
+ .map { |rp| expand_require_path(gem_dir, rp) }
79
81
  .select { |path| Dir.exist?(path) }
80
82
 
81
83
  lib_path = File.join(gem_dir, "lib")
82
84
  load_paths << lib_path if load_paths.empty? && Dir.exist?(lib_path)
83
- load_paths.concat(detect_nested_lib_paths(gem_dir))
84
85
  load_paths.uniq!
85
86
 
86
87
  ext_path = File.join(ruby_dir, "extensions",
@@ -102,7 +103,7 @@ module Scint
102
103
  end
103
104
 
104
105
  def detect_ruby_dir(bundle_dir)
105
- target = RUBY_VERSION.split(".")[0, 2].join(".") + ".0"
106
+ target = Platform.ruby_minor_version_dir
106
107
  preferred = File.join(bundle_dir, "ruby", target)
107
108
  return preferred if Dir.exist?(preferred)
108
109
 
@@ -111,13 +112,7 @@ module Scint
111
112
  end
112
113
 
113
114
  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}"
115
+ SpecUtils.full_name(spec)
121
116
  end
122
117
 
123
118
  def read_require_paths(spec_file)
@@ -130,20 +125,13 @@ module Scint
130
125
  ["lib"]
131
126
  end
132
127
 
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
128
+ def expand_require_path(gem_dir, require_path)
129
+ value = require_path.to_s
130
+ return value if Pathname.new(value).absolute?
143
131
 
144
- children
145
- .map { |entry| File.join(lib_dir, entry) }
146
- .select { |path| File.directory?(path) }
132
+ File.join(gem_dir, value)
133
+ rescue StandardError
134
+ File.join(gem_dir, require_path.to_s)
147
135
  end
148
136
  end
149
137
  end