scint 0.6.0 → 0.7.1
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 +4 -4
- data/FEATURES.md +4 -0
- data/README.md +161 -41
- data/VERSION +1 -1
- data/bin/scint +9 -0
- data/lib/bundler.rb +106 -0
- data/lib/scint/cache/layout.rb +72 -14
- data/lib/scint/cache/manifest.rb +120 -0
- data/lib/scint/cache/metadata_store.rb +4 -11
- data/lib/scint/cache/prewarm.rb +445 -33
- data/lib/scint/cache/validity.rb +134 -0
- data/lib/scint/cli/cache.rb +36 -7
- data/lib/scint/cli/exec.rb +13 -25
- data/lib/scint/cli/install.rb +1452 -164
- data/lib/scint/credentials.rb +78 -15
- data/lib/scint/debug/io_trace.rb +26 -7
- data/lib/scint/downloader/fetcher.rb +25 -1
- data/lib/scint/downloader/pool.rb +67 -15
- data/lib/scint/errors.rb +10 -0
- data/lib/scint/fs.rb +215 -26
- data/lib/scint/gem/package.rb +6 -2
- data/lib/scint/gemfile/parser.rb +44 -10
- data/lib/scint/installer/extension_builder.rb +80 -55
- data/lib/scint/installer/linker.rb +51 -26
- data/lib/scint/installer/planner.rb +53 -34
- data/lib/scint/installer/preparer.rb +170 -47
- data/lib/scint/installer/promoter.rb +97 -0
- data/lib/scint/linker.sh +137 -0
- data/lib/scint/lockfile/parser.rb +2 -1
- data/lib/scint/lockfile/writer.rb +85 -36
- data/lib/scint/platform.rb +8 -0
- data/lib/scint/resolver/provider.rb +15 -2
- data/lib/scint/runtime/exec.rb +52 -26
- data/lib/scint/runtime/setup.rb +29 -1
- data/lib/scint/scheduler.rb +6 -1
- data/lib/scint/spec_utils.rb +133 -0
- data/lib/scint.rb +1 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9c9934c3d5cfb4786daa6d766e031b27919cc290ffd9d3f17607e70f5f58535
|
|
4
|
+
data.tar.gz: ffc27f93068721d1fdfee4cb3fc62355beba8322c295968ae2f4cce6ccd4470e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e722c6d0c83bf1a6bbe2f4e4b65da8282f3b4f6ac83742900f91c158a93262092780b15778979bbbc9ccc59847c7d232f2c9bc689e4b01eb6b3920dd4a875347
|
|
7
|
+
data.tar.gz: 5c7a26fe05647b14e865b60b9f08adbb4f020edb0c18f393fd577a90af701a502471485bc6eaf90d947d1f82324f2d8fa322280f5916e243705300d1ca3defea
|
data/FEATURES.md
CHANGED
|
@@ -10,4 +10,8 @@
|
|
|
10
10
|
- the installation process involves compilation. We attempt to have compilation happen while its not blocking other operations, but also only one compilation at a time
|
|
11
11
|
- we have a book keeping object that governs the worker pools and that's present during each step (fetch, extract, compile, install) and recieves the tasks for each phase from the workers.
|
|
12
12
|
- i suspect that we need to fork of a worker for compilation which we then have to communicate with via some rpc format. simple "-> CALL method, <- RESULT:\n...." type line protocol through stdin/out might work well enough there.
|
|
13
|
+
- cache validity is defined by cached tree + spec marshal + versioned manifest, scoped by ABI (with `gem.build_complete` for native extensions)
|
|
14
|
+
- cached manifests are versioned JSON with deterministic ordering and a file list for fast materialization
|
|
15
|
+
- git cache slugs are deterministic hashes of the normalized source URI with collision detection + telemetry
|
|
16
|
+
- legacy cached entries without a manifest remain read-compatible but emit telemetry for deprecation
|
|
13
17
|
|
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
|
-
|
|
94
|
+
1. `inbound`
|
|
95
|
+
2. `assembling`
|
|
96
|
+
3. `cached`
|
|
97
|
+
4. `materialize`
|
|
70
98
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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[
|
|
83
|
-
B --> C[
|
|
84
|
-
C --> D[
|
|
85
|
-
D --> E[
|
|
86
|
-
E
|
|
87
|
-
|
|
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,121 @@ 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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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/`
|
|
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
|
+
## Cache Validity + Manifest Specification (Draft)
|
|
216
|
+
|
|
217
|
+
### Cache validity criteria
|
|
218
|
+
|
|
219
|
+
A cached artifact is considered valid only when *all* of the following are true:
|
|
220
|
+
|
|
221
|
+
1. `cached/<abi>/<full_name>/` exists and is a fully materialized tree.
|
|
222
|
+
2. `cached/<abi>/<full_name>.spec.marshal` exists and loads successfully.
|
|
223
|
+
3. `cached/<abi>/<full_name>.manifest` exists, parses, and its `version` is supported.
|
|
224
|
+
4. Manifest fields `full_name` and `abi` match the requested spec/ABI.
|
|
225
|
+
5. If the gem has native extensions, `ext/<abi>/<full_name>/gem.build_complete` exists.
|
|
226
|
+
|
|
227
|
+
Any failure means the cache entry is *invalid* and must be rebuilt (fetch/assemble/compile).
|
|
228
|
+
|
|
229
|
+
### Manifest schema
|
|
230
|
+
|
|
231
|
+
The manifest is a single UTF-8 JSON object written to
|
|
232
|
+
`cached/<abi>/<full_name>.manifest`. It is versioned and deterministically
|
|
233
|
+
serialized for repeatable cache reuse.
|
|
234
|
+
|
|
235
|
+
**Serialization rules**:
|
|
236
|
+
|
|
237
|
+
- Top-level keys sorted lexicographically.
|
|
238
|
+
- Array ordering is deterministic (see `files` below).
|
|
239
|
+
- JSON is emitted without extra whitespace (canonical/minified).
|
|
240
|
+
- No timestamps or host-specific values are stored.
|
|
241
|
+
|
|
242
|
+
**Schema (version 1)**:
|
|
243
|
+
|
|
244
|
+
- `version` (Integer, required): schema version (starting at `1`).
|
|
245
|
+
- `full_name` (String, required): `name-version(-platform)`.
|
|
246
|
+
- `abi` (String, required): Ruby ABI key (e.g. `ruby-3.4.5-arm64-darwin24`).
|
|
247
|
+
- `source` (Object, required):
|
|
248
|
+
- `type` (String): `rubygems`, `git`, or `path`.
|
|
249
|
+
- `uri` (String): canonical source URI.
|
|
250
|
+
- `revision` (String, git only): resolved commit SHA.
|
|
251
|
+
- `path` (String, path only): absolute source path.
|
|
252
|
+
- `files` (Array, required): sorted by `path` ascending. Each entry is:
|
|
253
|
+
- `path` (String): relative to the cached root.
|
|
254
|
+
- `type` (String): `file`, `dir`, or `symlink`.
|
|
255
|
+
- `mode` (Integer): numeric permission bits (e.g. `755`).
|
|
256
|
+
- `size` (Integer): bytes (directories use `0`).
|
|
257
|
+
- `sha256` (String, optional): content hash for files/symlinks.
|
|
258
|
+
- `build` (Object, required):
|
|
259
|
+
- `extensions` (Boolean): whether the gem builds native extensions.
|
|
260
|
+
- `ext_complete` (String, optional): completion marker path when extensions exist.
|
|
261
|
+
|
|
262
|
+
### Git slug normalization + collisions
|
|
263
|
+
|
|
264
|
+
Git cache directories use a deterministic slug derived from the source URI:
|
|
265
|
+
|
|
266
|
+
1. Normalize the URI to a stable string form (`uri.to_s`; callers should pass a
|
|
267
|
+
parsed URI for consistent normalization).
|
|
268
|
+
2. Compute `sha256(normalized_uri)`, use the first 16 hex characters.
|
|
269
|
+
|
|
270
|
+
**Collision handling**: when a slug directory already exists, validate that the
|
|
271
|
+
manifest `source.uri` matches the normalized URI. If it does not, treat it as a
|
|
272
|
+
collision, emit telemetry, and fall back to a longer hash (e.g. full 64 hex) or
|
|
273
|
+
an additional suffix.
|
|
274
|
+
|
|
275
|
+
### Legacy read-compat + telemetry
|
|
276
|
+
|
|
277
|
+
Legacy cache entries that lack a manifest (or use an unsupported schema version)
|
|
278
|
+
remain *read-compatible* for now:
|
|
279
|
+
|
|
280
|
+
- Treat the entry as valid only if the cached tree + `.spec.marshal` exist and
|
|
281
|
+
the gemspec loads cleanly.
|
|
282
|
+
- Emit telemetry counters (per run) such as `cache.manifest.missing`,
|
|
283
|
+
`cache.manifest.unsupported`, and `cache.manifest.collision`.
|
|
284
|
+
- Log a single warning per run with counts and cache root to guide deprecation.
|
|
285
|
+
|
|
286
|
+
## Warm Path Guarantees
|
|
287
|
+
|
|
288
|
+
Required behavior:
|
|
289
|
+
|
|
290
|
+
1. If `cached/<abi>/<full_name>/` exists and is valid, no fetch/extract/compile occurs for that gem.
|
|
291
|
+
2. Deleting only `.bundle/` should trigger only materialization work.
|
|
292
|
+
3. Materialization should be IO-bound and close to instantaneous on warm cache.
|
|
293
|
+
4. Incomplete assemblies must never be promoted; promotion is atomic.
|
|
294
|
+
|
|
175
295
|
## Concurrency Model
|
|
176
296
|
|
|
177
297
|
Scint parallelizes all non-conflicting work aggressively:
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.7.1
|
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
|
data/lib/scint/cache/layout.rb
CHANGED
|
@@ -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
|
|
|
@@ -22,10 +23,28 @@ module Scint
|
|
|
22
23
|
File.join(@root, "inbound")
|
|
23
24
|
end
|
|
24
25
|
|
|
26
|
+
def inbound_gems_dir
|
|
27
|
+
File.join(inbound_dir, "gems")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def inbound_gits_dir
|
|
31
|
+
File.join(inbound_dir, "gits")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def assembling_dir
|
|
35
|
+
File.join(@root, "assembling")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cached_dir
|
|
39
|
+
File.join(@root, "cached")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Legacy extracted cache (read-compat only).
|
|
25
43
|
def extracted_dir
|
|
26
44
|
File.join(@root, "extracted")
|
|
27
45
|
end
|
|
28
46
|
|
|
47
|
+
# Legacy extension cache (read-compat only).
|
|
29
48
|
def ext_dir
|
|
30
49
|
File.join(@root, "ext")
|
|
31
50
|
end
|
|
@@ -35,7 +54,7 @@ module Scint
|
|
|
35
54
|
end
|
|
36
55
|
|
|
37
56
|
def git_dir
|
|
38
|
-
|
|
57
|
+
inbound_gits_dir
|
|
39
58
|
end
|
|
40
59
|
|
|
41
60
|
# Isolated gem home used while compiling native extensions during install.
|
|
@@ -45,23 +64,46 @@ module Scint
|
|
|
45
64
|
end
|
|
46
65
|
|
|
47
66
|
def install_ruby_dir
|
|
48
|
-
|
|
67
|
+
Platform.ruby_install_dir(install_env_dir)
|
|
49
68
|
end
|
|
50
69
|
|
|
51
70
|
# -- Per-spec paths ------------------------------------------------------
|
|
52
71
|
|
|
53
72
|
def inbound_path(spec)
|
|
54
|
-
File.join(
|
|
73
|
+
File.join(inbound_gems_dir, "#{full_name(spec)}.gem")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def assembling_path(spec, abi_key = Platform.abi_key)
|
|
77
|
+
File.join(assembling_dir, abi_key, full_name(spec))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def cached_abi_dir(abi_key = Platform.abi_key)
|
|
81
|
+
File.join(cached_dir, abi_key)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cached_path(spec, abi_key = Platform.abi_key)
|
|
85
|
+
File.join(cached_dir, abi_key, full_name(spec))
|
|
55
86
|
end
|
|
56
87
|
|
|
88
|
+
def cached_spec_path(spec, abi_key = Platform.abi_key)
|
|
89
|
+
File.join(cached_dir, abi_key, "#{full_name(spec)}.spec.marshal")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def cached_manifest_path(spec, abi_key = Platform.abi_key)
|
|
93
|
+
File.join(cached_dir, abi_key, "#{full_name(spec)}.manifest")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Legacy extracted cache (read-compat only).
|
|
57
97
|
def extracted_path(spec)
|
|
58
98
|
File.join(extracted_dir, full_name(spec))
|
|
59
99
|
end
|
|
60
100
|
|
|
101
|
+
# Legacy extracted gemspec cache (read-compat only).
|
|
61
102
|
def spec_cache_path(spec)
|
|
62
103
|
File.join(extracted_dir, "#{full_name(spec)}.spec.marshal")
|
|
63
104
|
end
|
|
64
105
|
|
|
106
|
+
# Legacy extension cache (read-compat only).
|
|
65
107
|
def ext_path(spec, abi_key = Platform.abi_key)
|
|
66
108
|
File.join(ext_dir, abi_key, full_name(spec))
|
|
67
109
|
end
|
|
@@ -78,23 +120,20 @@ module Scint
|
|
|
78
120
|
end
|
|
79
121
|
|
|
80
122
|
def git_path(uri)
|
|
81
|
-
slug =
|
|
123
|
+
slug = git_slug(uri)
|
|
82
124
|
File.join(git_dir, slug)
|
|
83
125
|
end
|
|
84
126
|
|
|
127
|
+
def git_checkout_path(uri, revision)
|
|
128
|
+
slug = git_slug(uri)
|
|
129
|
+
rev = revision.to_s.gsub(/[^0-9A-Za-z._-]/, "_")
|
|
130
|
+
File.join(git_dir, slug, "checkouts", rev)
|
|
131
|
+
end
|
|
132
|
+
|
|
85
133
|
# -- Helpers -------------------------------------------------------------
|
|
86
134
|
|
|
87
135
|
def full_name(spec)
|
|
88
|
-
|
|
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
|
|
136
|
+
SpecUtils.full_name(spec)
|
|
98
137
|
end
|
|
99
138
|
|
|
100
139
|
# Ensure a directory exists (thread-safe, cached).
|
|
@@ -115,6 +154,10 @@ module Scint
|
|
|
115
154
|
File.join(base, "scint")
|
|
116
155
|
end
|
|
117
156
|
|
|
157
|
+
# Slug rules are defined in README.md (Cache Validity + Manifest Specification).
|
|
158
|
+
# - Index slugs prefer host/path when available, otherwise fall back to a hash.
|
|
159
|
+
# - Hash slugs are deterministic but must be paired with manifest checks for
|
|
160
|
+
# collision detection.
|
|
118
161
|
def slugify_uri(str)
|
|
119
162
|
uri = URI.parse(str) rescue nil
|
|
120
163
|
if uri && uri.host
|
|
@@ -126,6 +169,21 @@ module Scint
|
|
|
126
169
|
Digest::SHA256.hexdigest(str)[0, 16]
|
|
127
170
|
end
|
|
128
171
|
end
|
|
172
|
+
|
|
173
|
+
# Git slugs are SHA256 of the normalized URI string (uri.to_s), truncated
|
|
174
|
+
# to 16 hex chars. Callers must validate `source.uri` in the manifest to
|
|
175
|
+
# detect collisions and fall back to a longer hash if needed.
|
|
176
|
+
def git_slug(uri)
|
|
177
|
+
normalized = normalize_uri(uri)
|
|
178
|
+
Digest::SHA256.hexdigest(normalized)[0, 16]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def normalize_uri(uri)
|
|
182
|
+
return uri.to_s if uri.is_a?(URI)
|
|
183
|
+
URI.parse(uri.to_s).to_s
|
|
184
|
+
rescue URI::InvalidURIError
|
|
185
|
+
uri.to_s
|
|
186
|
+
end
|
|
129
187
|
end
|
|
130
188
|
end
|
|
131
189
|
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "find"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
require_relative "../fs"
|
|
8
|
+
require_relative "../spec_utils"
|
|
9
|
+
|
|
10
|
+
module Scint
|
|
11
|
+
module Cache
|
|
12
|
+
module Manifest
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
VERSION = 1
|
|
16
|
+
|
|
17
|
+
def build(spec:, gem_dir:, abi_key:, source:, extensions:)
|
|
18
|
+
{
|
|
19
|
+
"abi" => abi_key,
|
|
20
|
+
"build" => build_block(extensions: extensions),
|
|
21
|
+
"files" => collect_files(gem_dir),
|
|
22
|
+
"full_name" => SpecUtils.full_name(spec),
|
|
23
|
+
"source" => source,
|
|
24
|
+
"version" => VERSION,
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(path, manifest)
|
|
29
|
+
ordered = order_keys(manifest)
|
|
30
|
+
json = JSON.generate(ordered)
|
|
31
|
+
FS.atomic_write(path, json)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Write a flat file listing (one relative path per line) into the gem dir.
|
|
35
|
+
# This enables bulk install via a single `cp -al` or `cpio -pld` call.
|
|
36
|
+
DOTFILES_NAME = ".scint-files"
|
|
37
|
+
|
|
38
|
+
def write_dotfiles(gem_dir, manifest = nil)
|
|
39
|
+
files = if manifest
|
|
40
|
+
Array(manifest["files"]).filter_map { |e| e["path"] if e["type"] != "dir" }
|
|
41
|
+
else
|
|
42
|
+
collect_file_paths(gem_dir)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
dotfiles_path = File.join(gem_dir, DOTFILES_NAME)
|
|
46
|
+
File.write(dotfiles_path, files.sort.join("\n") + "\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def collect_file_paths(root)
|
|
50
|
+
paths = []
|
|
51
|
+
Find.find(root) do |path|
|
|
52
|
+
next if path == root
|
|
53
|
+
rel = path.delete_prefix("#{root}/")
|
|
54
|
+
next if rel == DOTFILES_NAME
|
|
55
|
+
stat = File.lstat(path)
|
|
56
|
+
paths << rel unless stat.directory?
|
|
57
|
+
end
|
|
58
|
+
paths
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def collect_files(root)
|
|
62
|
+
entries = []
|
|
63
|
+
Find.find(root) do |path|
|
|
64
|
+
next if path == root
|
|
65
|
+
|
|
66
|
+
rel = path.delete_prefix("#{root}/")
|
|
67
|
+
stat = File.lstat(path)
|
|
68
|
+
|
|
69
|
+
if stat.symlink?
|
|
70
|
+
target = File.readlink(path)
|
|
71
|
+
entries << file_entry(rel, "symlink", stat, Digest::SHA256.hexdigest(target))
|
|
72
|
+
elsif stat.directory?
|
|
73
|
+
entries << dir_entry(rel, stat)
|
|
74
|
+
else
|
|
75
|
+
entries << file_entry(rel, "file", stat, Digest::SHA256.file(path).hexdigest)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
entries.sort_by { |entry| entry["path"] }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_block(extensions:)
|
|
83
|
+
{ "extensions" => !!extensions }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def order_keys(object)
|
|
87
|
+
case object
|
|
88
|
+
when Hash
|
|
89
|
+
object.keys.sort.each_with_object({}) do |key, acc|
|
|
90
|
+
acc[key] = order_keys(object[key])
|
|
91
|
+
end
|
|
92
|
+
when Array
|
|
93
|
+
object.map { |entry| order_keys(entry) }
|
|
94
|
+
else
|
|
95
|
+
object
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def dir_entry(rel, stat)
|
|
100
|
+
{
|
|
101
|
+
"mode" => stat.mode & 0o777,
|
|
102
|
+
"path" => rel,
|
|
103
|
+
"size" => 0,
|
|
104
|
+
"type" => "dir",
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def file_entry(rel, type, stat, sha)
|
|
109
|
+
entry = {
|
|
110
|
+
"mode" => stat.mode & 0o777,
|
|
111
|
+
"path" => rel,
|
|
112
|
+
"size" => stat.size,
|
|
113
|
+
"type" => type,
|
|
114
|
+
}
|
|
115
|
+
entry["sha256"] = sha if sha
|
|
116
|
+
entry
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|