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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1c0c2f09127da28473aad491848c5b3e7b976fa41405a94ce542859fa1aabf8
4
- data.tar.gz: 2e7b1801b9a3004bc0d9709de9502bfff5a8b475178aa102ebb730a6cb1b6344
3
+ metadata.gz: d9c9934c3d5cfb4786daa6d766e031b27919cc290ffd9d3f17607e70f5f58535
4
+ data.tar.gz: ffc27f93068721d1fdfee4cb3fc62355beba8322c295968ae2f4cce6ccd4470e
5
5
  SHA512:
6
- metadata.gz: 3d95e5006966b2025c5e21951bc918849081c330a2872e7e665cafa6d73ab522871f7e41aa2ed4c61bbed37e73363f19650dce16bbc1e472b3e4fe54c0177d97
7
- data.tar.gz: 40fba5802be2359b85d201c2c732f8edac15f1e1be7ee17b2d59a39de9ac2f1ca6befd14d527fcacb4a5e763408c1296abe35301f8224a4b1138a5a4a1706096
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
- 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,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
- 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
+ ## 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.6.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
@@ -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
- File.join(@root, "git")
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
- File.join(install_env_dir, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
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(inbound_dir, "#{full_name(spec)}.gem")
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 = Digest::SHA256.hexdigest(uri.to_s)[0, 16]
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
- 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
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