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.
@@ -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