bundler-spinel 0.0.1.pre
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 +7 -0
- data/ARCHITECTURE.md +164 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.md +84 -0
- data/RFC.md +120 -0
- data/exe/spinel-compat +4 -0
- data/lib/bundler/spinel/checker.rb +58 -0
- data/lib/bundler/spinel/cli.rb +225 -0
- data/lib/bundler/spinel/command.rb +37 -0
- data/lib/bundler/spinel/engine.rb +96 -0
- data/lib/bundler/spinel/gem_fetcher.rb +45 -0
- data/lib/bundler/spinel/ledger.rb +97 -0
- data/lib/bundler/spinel/platform.rb +24 -0
- data/lib/bundler/spinel/probe.rb +182 -0
- data/lib/bundler/spinel/proxy.rb +154 -0
- data/lib/bundler/spinel/survey.rb +130 -0
- data/lib/bundler/spinel/vendorer.rb +90 -0
- data/lib/bundler/spinel/verifier.rb +100 -0
- data/lib/bundler/spinel/version.rb +7 -0
- data/lib/bundler/spinel.rb +15 -0
- data/plugins.rb +6 -0
- metadata +68 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
require_relative "../spinel"
|
|
2
|
+
|
|
3
|
+
module Bundler
|
|
4
|
+
module Spinel
|
|
5
|
+
# Thin command dispatcher shared by the `spinel-compat` executable and the
|
|
6
|
+
# Bundler plugin command. Keeps all logic in the library classes.
|
|
7
|
+
class CLI
|
|
8
|
+
VERDICT_GLYPH = {
|
|
9
|
+
"clean" => "✓", "verified" => "★", "risky" => "~", "rejected" => "✗"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(out: $stdout, err: $stderr)
|
|
13
|
+
@out = out
|
|
14
|
+
@err = err
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run(argv)
|
|
18
|
+
cmd = argv.shift
|
|
19
|
+
case cmd
|
|
20
|
+
when "engine" then cmd_engine
|
|
21
|
+
when "probe" then cmd_probe(argv)
|
|
22
|
+
when "verify" then cmd_verify(argv)
|
|
23
|
+
when "vendor" then cmd_vendor(argv)
|
|
24
|
+
when "check" then cmd_check(argv)
|
|
25
|
+
when "serve" then cmd_serve(argv)
|
|
26
|
+
when "build-index" then cmd_build_index(argv)
|
|
27
|
+
when "ledger" then cmd_ledger(argv)
|
|
28
|
+
when "reprobe" then cmd_reprobe(argv)
|
|
29
|
+
when "survey" then cmd_survey(argv)
|
|
30
|
+
when nil, "-h", "--help", "help" then usage; 0
|
|
31
|
+
else
|
|
32
|
+
@err.puts "unknown command: #{cmd}"; usage; 2
|
|
33
|
+
end
|
|
34
|
+
rescue Error => e
|
|
35
|
+
@err.puts "[spinel-compat] #{e.message}"; 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def cmd_engine
|
|
41
|
+
e = Engine.new
|
|
42
|
+
@out.puts "spinel binary : #{e.bin} (#{e.available? ? 'found' : 'MISSING'})"
|
|
43
|
+
@out.puts "engine rev : #{e.rev}"
|
|
44
|
+
@out.puts "ledger : #{Ledger.new.path}"
|
|
45
|
+
e.available? ? 0 : 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def cmd_probe(argv)
|
|
49
|
+
dir = (i = argv.index("--dir")) ? argv.delete_at(i + 1).tap { argv.delete_at(i) } : nil
|
|
50
|
+
name = argv.shift or raise Error, "usage: spinel-compat probe NAME [VERSION] [--dir PATH]"
|
|
51
|
+
engine = Engine.new
|
|
52
|
+
if dir
|
|
53
|
+
# Local source (a path:/git: sibling, or a checkout under test).
|
|
54
|
+
version = argv.shift || "path"
|
|
55
|
+
v = Probe.new(engine, Ledger.new).probe(name, version, File.expand_path(dir))
|
|
56
|
+
else
|
|
57
|
+
version = argv.shift || latest_version(name)
|
|
58
|
+
v = Probe.new(engine, Ledger.new).probe(name, version, GemFetcher.new.fetch(name, version))
|
|
59
|
+
end
|
|
60
|
+
print_verdict(v)
|
|
61
|
+
v.rejected? ? 1 : 0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def cmd_verify(argv)
|
|
65
|
+
dir = (i = argv.index("--dir")) ? argv.delete_at(i + 1).tap { argv.delete_at(i) } : nil
|
|
66
|
+
smoke = (j = argv.index("--smoke")) ? argv.delete_at(j + 1).tap { argv.delete_at(j) } : nil
|
|
67
|
+
name = argv.shift or raise Error, "usage: spinel-compat verify NAME [VERSION] [--dir PATH] [--smoke FILE]"
|
|
68
|
+
engine = Engine.new
|
|
69
|
+
if dir
|
|
70
|
+
version = argv.shift || "path"
|
|
71
|
+
gem_dir = File.expand_path(dir)
|
|
72
|
+
else
|
|
73
|
+
version = argv.shift || latest_version(name)
|
|
74
|
+
gem_dir = GemFetcher.new.fetch(name, version)
|
|
75
|
+
end
|
|
76
|
+
v = Verifier.new(engine, Ledger.new).verify(name, version, gem_dir, smoke: smoke && File.expand_path(smoke))
|
|
77
|
+
print_verdict(v)
|
|
78
|
+
v.verified? ? 0 : 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cmd_vendor(argv)
|
|
82
|
+
into = (i = argv.index("--into")) ? argv.delete_at(i + 1).tap { argv.delete_at(i) } : "vendor/spinel"
|
|
83
|
+
lock = argv.shift || "Gemfile.lock"
|
|
84
|
+
raise Error, "no #{lock}; run `bundle lock` first" unless File.exist?(lock)
|
|
85
|
+
|
|
86
|
+
res = Vendorer.new.vendor(lock, into: into)
|
|
87
|
+
@out.puts "vendored #{res[:count]} gem(s) -> #{res[:into]}"
|
|
88
|
+
@out.puts " require_relative \"#{res[:into]}/deps\" from your Spinel entrypoint"
|
|
89
|
+
0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def cmd_check(argv)
|
|
93
|
+
strict = argv.delete("--strict")
|
|
94
|
+
lock = argv.shift || "Gemfile.lock"
|
|
95
|
+
raise Error, "no #{lock}; run `bundle lock` first" unless File.exist?(lock)
|
|
96
|
+
|
|
97
|
+
res = Checker.new.check(lock, strict: !!strict)
|
|
98
|
+
res.probed.sort_by(&:gem).each { |v| print_verdict(v) }
|
|
99
|
+
@out.puts "—" * 48
|
|
100
|
+
if res.verdict
|
|
101
|
+
@out.puts "OK: #{res.probed.size} gems compatible with #{Engine.new.rev}" \
|
|
102
|
+
"#{strict ? ' (strict)' : ''}"
|
|
103
|
+
0
|
|
104
|
+
else
|
|
105
|
+
@err.puts "REJECTED under #{Engine.new.rev}:"
|
|
106
|
+
res.rejected.each { |v| @err.puts " ✗ #{v.gem} #{v.version} — #{v.reasons.join(', ')}" }
|
|
107
|
+
res.risky.each { |v| @err.puts " ~ #{v.gem} #{v.version} — risky: #{v.risks.join(', ')}" } if strict
|
|
108
|
+
1
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def cmd_serve(argv)
|
|
113
|
+
require_relative "proxy"
|
|
114
|
+
store = (i = argv.index("--store")) ? argv[i + 1] : raise(Error, "serve needs --store DIR (vetted .gem files)")
|
|
115
|
+
port = (j = argv.index("--port")) ? argv[j + 1].to_i : 9292
|
|
116
|
+
min = (k = argv.index("--min")) ? argv[k + 1].to_sym : :verified
|
|
117
|
+
Proxy.new(store: File.expand_path(store), min_verdict: min).serve(port: port)
|
|
118
|
+
0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def cmd_build_index(argv)
|
|
122
|
+
require_relative "proxy"
|
|
123
|
+
store = (i = argv.index("--store")) ? argv[i + 1] : raise(Error, "build-index needs --store DIR")
|
|
124
|
+
out = (j = argv.index("--out")) ? argv[j + 1] : raise(Error, "build-index needs --out DIR")
|
|
125
|
+
min = (k = argv.index("--min")) ? argv[k + 1].to_sym : :verified
|
|
126
|
+
dir = Proxy.new(store: File.expand_path(store), min_verdict: min).write_static(File.expand_path(out))
|
|
127
|
+
@out.puts "wrote static curated index to #{dir} (serve it as a `source`)"
|
|
128
|
+
0
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def cmd_ledger(argv)
|
|
132
|
+
rev = (i = argv.index("--rev")) ? argv[i + 1] : nil
|
|
133
|
+
Ledger.new.each do |v|
|
|
134
|
+
next if rev && v.rev != rev
|
|
135
|
+
|
|
136
|
+
print_verdict(v, show_rev: true)
|
|
137
|
+
end
|
|
138
|
+
0
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Forward-compat sweep: re-probe every gem the ledger has ever seen under
|
|
142
|
+
# the *current* engine rev, surfacing what newly passes after a Spinel
|
|
143
|
+
# upgrade. Skips triples already probed at this rev.
|
|
144
|
+
def cmd_reprobe(_argv)
|
|
145
|
+
engine = Engine.new
|
|
146
|
+
ledger = Ledger.new
|
|
147
|
+
probe = Probe.new(engine, ledger)
|
|
148
|
+
flips = 0
|
|
149
|
+
ledger.known_gems.each do |name, version|
|
|
150
|
+
next if ledger.lookup(name, version, engine.rev)
|
|
151
|
+
|
|
152
|
+
dir = GemFetcher.new.fetch(name, version)
|
|
153
|
+
v = probe.probe(name, version, dir)
|
|
154
|
+
flips += 1
|
|
155
|
+
print_verdict(v)
|
|
156
|
+
rescue Error => e
|
|
157
|
+
@err.puts " ! #{name} #{version}: #{e.message}"
|
|
158
|
+
end
|
|
159
|
+
@out.puts "re-probed #{flips} gem(s) under #{engine.rev}"
|
|
160
|
+
0
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Wholesale review: probe a list of gems in parallel, then emit a report
|
|
164
|
+
# (the rejection-reason histogram prioritises Spinel's roadmap).
|
|
165
|
+
def cmd_survey(argv)
|
|
166
|
+
jobs = (i = argv.index("--jobs")) ? argv.delete_at(i + 1).to_i.tap { argv.delete_at(i) } : 4
|
|
167
|
+
out_file = (j = argv.index("--out")) ? argv.delete_at(j + 1).tap { argv.delete_at(j) } : nil
|
|
168
|
+
list = (k = argv.index("--list")) ? argv.delete_at(k + 1).tap { argv.delete_at(k) } : nil
|
|
169
|
+
names = if list
|
|
170
|
+
File.readlines(File.expand_path(list)).map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
|
|
171
|
+
else
|
|
172
|
+
argv.dup
|
|
173
|
+
end
|
|
174
|
+
raise Error, "usage: spinel-compat survey GEM... | --list FILE [--jobs N] [--out report.md]" if names.empty?
|
|
175
|
+
|
|
176
|
+
survey = Survey.new(jobs: jobs)
|
|
177
|
+
survey.run(names)
|
|
178
|
+
report = survey.report(names)
|
|
179
|
+
if out_file
|
|
180
|
+
File.write(File.expand_path(out_file), report)
|
|
181
|
+
@out.puts "wrote #{out_file} (#{names.size} gems)"
|
|
182
|
+
else
|
|
183
|
+
@out.puts report
|
|
184
|
+
end
|
|
185
|
+
0
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def print_verdict(v, show_rev: false)
|
|
189
|
+
glyph = VERDICT_GLYPH[v.verdict] || "?"
|
|
190
|
+
labels = v.reasons + v.risks.map { |r| r.include?(":") ? r : "risk:#{r}" }
|
|
191
|
+
tail = labels.empty? ? "" : " — #{labels.join(', ')}"
|
|
192
|
+
rev = show_rev ? " [#{v.rev}]" : ""
|
|
193
|
+
@out.puts format(" %s %-22s %-10s %-9s%s%s", glyph, v.gem, v.version, v.verdict, tail, rev)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def latest_version(name)
|
|
197
|
+
require "open3"
|
|
198
|
+
out, st = Open3.capture2e("gem", "list", "-r", "-e", name)
|
|
199
|
+
raise Error, "cannot resolve latest version of #{name}" unless st.success?
|
|
200
|
+
|
|
201
|
+
out[/#{Regexp.escape(name)} \(([^,)]+)/, 1] or
|
|
202
|
+
raise Error, "no remote versions found for #{name}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def usage
|
|
206
|
+
@out.puts <<~USAGE
|
|
207
|
+
spinel-compat — Spinel gem-compatibility ledger
|
|
208
|
+
|
|
209
|
+
spinel-compat engine show detected compiler + engine rev
|
|
210
|
+
spinel-compat probe NAME [VERSION] probe one gem, record a verdict
|
|
211
|
+
spinel-compat verify NAME [--smoke F] differential CRuby-vs-Spinel run -> verified
|
|
212
|
+
spinel-compat vendor [LOCK] [--into D] place deps where Spinel finds them + deps.rb
|
|
213
|
+
spinel-compat check [LOCK] [--strict] gate a Gemfile.lock (exit 1 if rejected)
|
|
214
|
+
spinel-compat survey GEM... | --list F wholesale review -> reason histogram
|
|
215
|
+
spinel-compat serve --store DIR curated source (only vetted gems)
|
|
216
|
+
spinel-compat build-index --store DIR --out DIR static curated index
|
|
217
|
+
spinel-compat ledger [--rev REV] dump recorded verdicts
|
|
218
|
+
spinel-compat reprobe re-probe known gems under current rev
|
|
219
|
+
|
|
220
|
+
Verdicts: ✓ clean ★ verified ~ risky ✗ rejected
|
|
221
|
+
USAGE
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require_relative "../spinel"
|
|
2
|
+
require_relative "cli"
|
|
3
|
+
|
|
4
|
+
module Bundler
|
|
5
|
+
module Spinel
|
|
6
|
+
# Bundler plugin command. Registered in ../../../plugins.rb so that, once
|
|
7
|
+
# installed via `bundle plugin install bundler-spinel`, these run as
|
|
8
|
+
# first-class bundler subcommands:
|
|
9
|
+
#
|
|
10
|
+
# bundle spinel-lock # `bundle lock`, then gate the resulting lockfile
|
|
11
|
+
# bundle spinel-check # gate an existing Gemfile.lock
|
|
12
|
+
#
|
|
13
|
+
# `spinel-lock` is the headline: it makes the Spinel-incompatibility failure
|
|
14
|
+
# land at resolution time. `bundle lock` itself ignores the `engine: spinel`
|
|
15
|
+
# directive (it resolves fine); the engine guard only fires at `bundle
|
|
16
|
+
# install`. This wraps lock so the *compatibility* gate fires immediately
|
|
17
|
+
# after resolution instead of waiting for a compile that may silently
|
|
18
|
+
# mis-emit rather than fail.
|
|
19
|
+
class Command < Bundler::Plugin::API
|
|
20
|
+
def exec(command, args)
|
|
21
|
+
cli = CLI.new
|
|
22
|
+
case command
|
|
23
|
+
when "spinel-lock"
|
|
24
|
+
unless system("bundle", "lock", *args)
|
|
25
|
+
exit($?.exitstatus.nonzero? || 1)
|
|
26
|
+
end
|
|
27
|
+
exit cli.run(["check", "Gemfile.lock"])
|
|
28
|
+
when "spinel-check"
|
|
29
|
+
exit cli.run(["check", *args])
|
|
30
|
+
else
|
|
31
|
+
warn "bundler-spinel: unknown command #{command}"
|
|
32
|
+
exit 2
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Bundler
|
|
6
|
+
module Spinel
|
|
7
|
+
# Locates the Spinel compiler and derives a stable *revision* identity for it.
|
|
8
|
+
#
|
|
9
|
+
# Forward-compatibility hinges on this: every compatibility verdict in the
|
|
10
|
+
# ledger is keyed on the engine revision, never on a gem name alone. Spinel
|
|
11
|
+
# ships no version string (`spinel --version` just prints usage; `git
|
|
12
|
+
# describe` returns a bare SHA with no tags), so we key on the git revision
|
|
13
|
+
# of the Spinel checkout — falling back to a content hash of the binary when
|
|
14
|
+
# it isn't a git checkout. Upgrade Spinel → new rev → no ledger entry → the
|
|
15
|
+
# gem is re-probed automatically. A gem that is `rejected` today and works
|
|
16
|
+
# after a Spinel feature lands flips to `clean` on the next probe, with no
|
|
17
|
+
# manual blocklist to maintain.
|
|
18
|
+
class Engine
|
|
19
|
+
attr_reader :dir, :bin
|
|
20
|
+
|
|
21
|
+
def initialize(dir: ENV.fetch("SPINEL_DIR", File.expand_path("~/spinel")))
|
|
22
|
+
@dir = dir
|
|
23
|
+
# Prefer a checkout at `dir` (gives a git rev); otherwise fall back to a
|
|
24
|
+
# `spinel` on PATH (installed binary — rev becomes a binary hash). This
|
|
25
|
+
# makes the default work for most setups without configuration.
|
|
26
|
+
local = File.join(dir, "spinel")
|
|
27
|
+
@bin = File.executable?(local) ? local : (which("spinel") || local)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def available?
|
|
31
|
+
File.executable?(@bin)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ensure!
|
|
35
|
+
return if available?
|
|
36
|
+
|
|
37
|
+
raise Error, "spinel compiler not found at #{@bin} " \
|
|
38
|
+
"(set SPINEL_DIR or pass --spinel-dir)"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Short, human-facing engine id, platform-scoped:
|
|
42
|
+
# "git:0adca86/arm64-darwin" or "git:0adca86+dirty/aarch64-linux".
|
|
43
|
+
# Platform matters because verdicts that depend on the C compile + runtime
|
|
44
|
+
# (analyze-failed, miscompile, build-error, verified) are not portable
|
|
45
|
+
# across targets — only `unresolved:X` (pure analysis) is. So the same
|
|
46
|
+
# Spinel commit built on the Mac and on the gx10 are *distinct* ledger revs.
|
|
47
|
+
def rev
|
|
48
|
+
@rev ||= "#{compute_rev}/#{platform}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def platform
|
|
52
|
+
cpu = RbConfig::CONFIG["host_cpu"]
|
|
53
|
+
os = RbConfig::CONFIG["host_os"].sub(/\d.*\z/, "").sub(/darwin.*/, "darwin")
|
|
54
|
+
"#{cpu}-#{os}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The label a Gemfile declares via `engine_version:`. Advisory only — the
|
|
58
|
+
# ledger key is `rev`, not this. Recorded so `check` can warn when the
|
|
59
|
+
# declared label and the actual binary drift apart.
|
|
60
|
+
def declared_version
|
|
61
|
+
@declared_version
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
attr_writer :declared_version
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def compute_rev
|
|
69
|
+
if File.directory?(File.join(@dir, ".git"))
|
|
70
|
+
sha = capture("git", "-C", @dir, "rev-parse", "--short", "HEAD")
|
|
71
|
+
if sha && !sha.empty?
|
|
72
|
+
dirty = capture("git", "-C", @dir, "status", "--porcelain")
|
|
73
|
+
return "git:#{sha}" + (dirty.to_s.strip.empty? ? "" : "+dirty")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
# Not a git checkout: hash the binary so distinct builds get distinct keys.
|
|
77
|
+
return "missing" unless available?
|
|
78
|
+
|
|
79
|
+
"bin:#{Digest::SHA256.file(@bin).hexdigest[0, 12]}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def which(cmd)
|
|
83
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
|
|
84
|
+
.map { |p| File.join(p, cmd) }
|
|
85
|
+
.find { |f| File.executable?(f) && !File.directory?(f) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def capture(*cmd)
|
|
89
|
+
out, st = Open3.capture2e(*cmd)
|
|
90
|
+
st.success? ? out.strip : nil
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Bundler
|
|
6
|
+
module Spinel
|
|
7
|
+
# Downloads and unpacks a gem's *source* so the probe can compile it.
|
|
8
|
+
# Reuses RubyGems' own `gem fetch` / `gem unpack` — we want the source tree,
|
|
9
|
+
# not an install. Sources are cached under a content dir so re-probes are
|
|
10
|
+
# free.
|
|
11
|
+
class GemFetcher
|
|
12
|
+
CACHE = File.expand_path("~/.cache/spinel-compat/gems")
|
|
13
|
+
|
|
14
|
+
def initialize(cache: CACHE)
|
|
15
|
+
@cache = cache
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns the path to the unpacked gem dir, fetching+unpacking if needed.
|
|
19
|
+
def fetch(name, version)
|
|
20
|
+
dest = File.join(@cache, "#{name}-#{version}")
|
|
21
|
+
return dest if File.directory?(dest)
|
|
22
|
+
|
|
23
|
+
FileUtils.mkdir_p(@cache)
|
|
24
|
+
Dir.mktmpdir do |tmp|
|
|
25
|
+
out, st = Open3.capture2e(
|
|
26
|
+
"gem", "fetch", name, "-v", version, "--platform", "ruby",
|
|
27
|
+
chdir: tmp
|
|
28
|
+
)
|
|
29
|
+
raise Error, "gem fetch #{name} #{version} failed:\n#{out}" unless st.success?
|
|
30
|
+
|
|
31
|
+
gemfile = Dir[File.join(tmp, "#{name}-*.gem")].first
|
|
32
|
+
raise Error, "no .gem produced for #{name} #{version}" unless gemfile
|
|
33
|
+
|
|
34
|
+
out, st = Open3.capture2e("gem", "unpack", gemfile, "--target", @cache)
|
|
35
|
+
raise Error, "gem unpack failed:\n#{out}" unless st.success?
|
|
36
|
+
end
|
|
37
|
+
# `gem unpack` may name the dir with a platform suffix; normalise.
|
|
38
|
+
return dest if File.directory?(dest)
|
|
39
|
+
|
|
40
|
+
actual = Dir[File.join(@cache, "#{name}-#{version}*")].find { |d| File.directory?(d) }
|
|
41
|
+
actual or raise Error, "unpacked dir for #{name} #{version} not found"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "time"
|
|
4
|
+
require "thread"
|
|
5
|
+
|
|
6
|
+
module Bundler
|
|
7
|
+
module Spinel
|
|
8
|
+
# Append-only JSONL record of compatibility verdicts, one line per
|
|
9
|
+
# `(gem, version, engine_rev)`. The single source of truth that the
|
|
10
|
+
# lock-time gate, the curated-source whitelist, and the platform-variant
|
|
11
|
+
# opt-in are all views over.
|
|
12
|
+
#
|
|
13
|
+
# Append-only because verdicts are facts-as-of-a-rev: we never mutate
|
|
14
|
+
# history, we add a newer probe. `lookup` returns the most recent matching
|
|
15
|
+
# line, so a re-probe naturally supersedes an older one.
|
|
16
|
+
class Ledger
|
|
17
|
+
Verdict = Struct.new(
|
|
18
|
+
:gem, :version, :rev, :verdict, :reasons, :risks, :probe, :at,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
) do
|
|
21
|
+
# Hard no: will not compile, or compiles to silent no-ops we detected.
|
|
22
|
+
def rejected? = verdict == "rejected"
|
|
23
|
+
# Compiles clean *and* no risky dynamic constructs found statically.
|
|
24
|
+
def clean? = verdict == "clean"
|
|
25
|
+
# Compiles clean but uses constructs Spinel degrades silently
|
|
26
|
+
# (define_method/eval/…): allowed by default, fails under --strict.
|
|
27
|
+
def risky? = verdict == "risky"
|
|
28
|
+
# Compiles clean AND the gem's own tests pass through a Spinel-compiled
|
|
29
|
+
# harness. The only verdict that earns a whitelist slot / platform badge.
|
|
30
|
+
def verified? = verdict == "verified"
|
|
31
|
+
|
|
32
|
+
def to_line = JSON.generate(to_h.transform_keys(&:to_s))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
DEFAULT_PATH = File.expand_path("../../../ledger/compat.jsonl", __dir__)
|
|
36
|
+
|
|
37
|
+
attr_reader :path
|
|
38
|
+
|
|
39
|
+
def initialize(path: ENV.fetch("SPINEL_COMPAT_LEDGER", DEFAULT_PATH))
|
|
40
|
+
@path = path
|
|
41
|
+
@write_mutex = Mutex.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Thread-safe: the survey probes gems in parallel and records from many
|
|
45
|
+
# threads. One `write` of the whole line under O_APPEND is a single
|
|
46
|
+
# atomic syscall, so a concurrent `each`/`lookup` reader never sees a
|
|
47
|
+
# torn line; the mutex just serialises writers among themselves.
|
|
48
|
+
def record(verdict)
|
|
49
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
50
|
+
@write_mutex.synchronize do
|
|
51
|
+
File.open(@path, "a") { |f| f.write("#{verdict.to_line}\n") }
|
|
52
|
+
end
|
|
53
|
+
verdict
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Most recent verdict for this exact triple, or nil.
|
|
57
|
+
def lookup(gem, version, rev)
|
|
58
|
+
result = nil
|
|
59
|
+
each { |v| result = v if v.gem == gem && v.version == version && v.rev == rev }
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Every distinct (gem, version) the ledger has ever seen — the candidate
|
|
64
|
+
# set for a forward-compat re-probe sweep under a new rev.
|
|
65
|
+
def known_gems
|
|
66
|
+
seen = {}
|
|
67
|
+
each { |v| seen[[v.gem, v.version]] = true }
|
|
68
|
+
seen.keys
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def each
|
|
72
|
+
return enum_for(:each) unless block_given?
|
|
73
|
+
return unless File.exist?(@path)
|
|
74
|
+
|
|
75
|
+
File.foreach(@path) do |line|
|
|
76
|
+
line = line.strip
|
|
77
|
+
next if line.empty?
|
|
78
|
+
|
|
79
|
+
h = JSON.parse(line)
|
|
80
|
+
yield Verdict.new(
|
|
81
|
+
gem: h["gem"], version: h["version"], rev: h["rev"],
|
|
82
|
+
verdict: h["verdict"], reasons: h["reasons"] || [],
|
|
83
|
+
risks: h["risks"] || [], probe: h["probe"], at: h["at"]
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build(gem:, version:, rev:, verdict:, reasons: [], risks: [], probe: "compile")
|
|
89
|
+
Verdict.new(
|
|
90
|
+
gem: gem, version: version, rev: rev, verdict: verdict,
|
|
91
|
+
reasons: reasons, risks: risks, probe: probe,
|
|
92
|
+
at: Time.now.utc.iso8601
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Bundler
|
|
2
|
+
module Spinel
|
|
3
|
+
# STUB — designed, not built. See ARCHITECTURE.md §"Platform variant".
|
|
4
|
+
#
|
|
5
|
+
# Opt-in mechanism to mark a gem "verified under Spinel" using RubyGems'
|
|
6
|
+
# existing platform machinery — the same lever JRuby uses with the `java`
|
|
7
|
+
# platform. A `verified` gem can be republished (to the curated source) with
|
|
8
|
+
# a `spinel` platform; Bundler's normal platform resolution then *prefers*
|
|
9
|
+
# the spinel variant and a missing variant is a clean resolution miss.
|
|
10
|
+
#
|
|
11
|
+
# `Gem::Platform.new("spinel")` parses to os="unknown" today, so the concrete
|
|
12
|
+
# token is TBD (likely "<cpu>-spinel-<engine_rev>" so the badge is rev-scoped
|
|
13
|
+
# — a variant verified under git:0adca86 should not silently satisfy a newer
|
|
14
|
+
# engine). This is the bridge from "our private ledger says verified" to
|
|
15
|
+
# "stock Bundler resolution selects it," and the reason verification is
|
|
16
|
+
# opt-in: earning a platform badge means someone ran the gem's tests through
|
|
17
|
+
# a Spinel-compiled harness.
|
|
18
|
+
class Platform
|
|
19
|
+
def initialize(*)
|
|
20
|
+
raise Error, "Platform is a design stub — see ARCHITECTURE.md"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|