bundler-spinel 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -0
- data/lib/bundler/spinel/cli.rb +81 -4
- data/lib/bundler/spinel/engine.rb +11 -1
- data/lib/bundler/spinel/history.rb +84 -1
- data/lib/bundler/spinel/load_bearing.rb +116 -0
- data/lib/bundler/spinel/localizer.rb +11 -9
- data/lib/bundler/spinel/probe.rb +51 -5
- data/lib/bundler/spinel/site.rb +23 -5
- data/lib/bundler/spinel/vendorer.rb +317 -17
- data/lib/bundler/spinel/verifier.rb +25 -4
- data/lib/bundler/spinel/version.rb +8 -5
- data/lib/bundler/spinel/why.rb +219 -0
- data/lib/bundler/spinel.rb +1 -0
- metadata +5 -6
data/lib/bundler/spinel/probe.rb
CHANGED
|
@@ -37,6 +37,25 @@ module Bundler
|
|
|
37
37
|
# and stdlib deps surface here. Informational, NOT a standalone reject:
|
|
38
38
|
# it's as often a probe limitation as a real incompatibility.
|
|
39
39
|
UNRESOLVED_REQUIRE = /require "([^"]+)" could not be resolved/.freeze
|
|
40
|
+
# Since the #1383 fix, an unresolvable plain `require` no longer warns +
|
|
41
|
+
# continues (exit 0) — it's emitted as an unsupported CallNode and the
|
|
42
|
+
# compile EXITS NON-ZERO. On the whole 189k corpus that turned ~thousands
|
|
43
|
+
# of previously-`clean` gems (the universal `require "gem/version"` idiom)
|
|
44
|
+
# into spurious `analyze-failed`. We detect this exact form so a
|
|
45
|
+
# require-ONLY compile failure is classified as the no-load-path
|
|
46
|
+
# limitation, not a codegen failure. (b60fbd7; see harness/findings.)
|
|
47
|
+
REQUIRE_CALL_FAIL = /unsupported call:.*CallNode `require`/.freeze
|
|
48
|
+
# A genuine codegen/compile failure — the C compiler choked, the analyzer
|
|
49
|
+
# died, or an unsupported construct OTHER than a plain require. Presence of
|
|
50
|
+
# any of these means the failure is real, not just the load-path limit.
|
|
51
|
+
HARD_COMPILE_ERROR = %r{
|
|
52
|
+
out\.c:\d+.*\berror: | # gcc error on emitted C
|
|
53
|
+
\bfatal\b |
|
|
54
|
+
\bSegmentation\ fault\b |
|
|
55
|
+
\banalyze\ failed\b |
|
|
56
|
+
unsupported\ puts\ argument | # an unsupported non-require construct
|
|
57
|
+
unsupported\ call:\ (?!.*CallNode\ `require`) # any non-require unsupported call
|
|
58
|
+
}ix.freeze
|
|
40
59
|
ANALYZE_FAILED = /\b(analyze failed|fatal)\b/i.freeze
|
|
41
60
|
|
|
42
61
|
# Spinel's analyze pass can spin for minutes on pathological inputs (no
|
|
@@ -53,15 +72,26 @@ module Bundler
|
|
|
53
72
|
# Spinel will never support (threads, Mutex, TracePoint). Metaprogramming
|
|
54
73
|
# tokens like `define_method` stay in RISK_TOKENS below — they degrade
|
|
55
74
|
# silently, so the compile signal is still the right call there.
|
|
75
|
+
# Constructs that put a gem outside the AOT closed-world model entirely —
|
|
76
|
+
# rejected from a static scan, before a compile is even attempted.
|
|
77
|
+
# Thread/Mutex lived here until matz/spinel#1360 made them *run* (single-
|
|
78
|
+
# threaded, carrying the block's value): they're now compiled + flagged
|
|
79
|
+
# `risky` (below), not hard-rejected. TracePoint/set_trace_func stay —
|
|
80
|
+
# there is no degenerate-but-correct lowering for runtime tracing.
|
|
56
81
|
HARD_REJECT_TOKENS = {
|
|
57
|
-
/\
|
|
58
|
-
/\
|
|
59
|
-
/\bMutex_m\b/ => "Mutex_m",
|
|
60
|
-
/\bTracePoint\b/ => "TracePoint"
|
|
82
|
+
/\bTracePoint\b/ => "TracePoint",
|
|
83
|
+
/\bset_trace_func\b/ => "set_trace_func"
|
|
61
84
|
}.freeze
|
|
62
85
|
|
|
63
86
|
# token => reason. Tokens Spinel cannot honour and may silently no-op.
|
|
64
87
|
RISK_TOKENS = {
|
|
88
|
+
# Thread/Mutex run single-threaded since matz/spinel#1360 — correct for
|
|
89
|
+
# defensive use (a mutex guarding state, Thread.new for a value), but
|
|
90
|
+
# degenerate for genuine concurrency: compiles, flagged, fails
|
|
91
|
+
# `check --strict`. (Demoted from HARD_REJECT after #1360.)
|
|
92
|
+
/\bThread\.(new|start|fork)\b/ => "thread",
|
|
93
|
+
/\bMutex\.new\b/ => "mutex",
|
|
94
|
+
/\bMutex_m\b/ => "mutex_m",
|
|
65
95
|
/\beval\s*\(/ => "eval",
|
|
66
96
|
/\binstance_eval\b/ => "instance_eval",
|
|
67
97
|
/\b(class|module)_eval\b/ => "class_eval",
|
|
@@ -108,6 +138,13 @@ module Bundler
|
|
|
108
138
|
elsif sig[:timed_out]
|
|
109
139
|
# Analyzer ran past the cap — pathological for Spinel, not the gem.
|
|
110
140
|
["rejected", ["analyze-timeout"]]
|
|
141
|
+
elsif sig[:require_only_fail]
|
|
142
|
+
# Compile failed solely on an unresolvable plain `require` (b60fbd7
|
|
143
|
+
# hard-fails these where cb23cc6 warned + continued). That's the
|
|
144
|
+
# no-load-path limitation, not a codegen failure — a real Spinel
|
|
145
|
+
# project vendors deps so the require resolves. Classify as the
|
|
146
|
+
# load-path limit (risky), keeping the needs: notes below.
|
|
147
|
+
["risky", ["load-path:require"]]
|
|
111
148
|
elsif sig[:analyze_failed] || !sig[:exit_ok]
|
|
112
149
|
["rejected", ["analyze-failed"]]
|
|
113
150
|
elsif !static_only_risks(risks).empty?
|
|
@@ -134,18 +171,27 @@ module Bundler
|
|
|
134
171
|
analyze_failed = false
|
|
135
172
|
exit_ok = true
|
|
136
173
|
timed_out = false
|
|
174
|
+
require_fail = false # saw the new unresolvable-require hard-fail form
|
|
175
|
+
hard_error = false # saw a genuine codegen/analyzer failure
|
|
137
176
|
Dir.mktmpdir do |tmp|
|
|
138
177
|
entries.each do |f|
|
|
139
178
|
out, ok, hit_timeout = run_spinel(f, File.join(tmp, "out.c"))
|
|
140
179
|
exit_ok &&= ok
|
|
141
180
|
timed_out ||= hit_timeout
|
|
142
181
|
analyze_failed ||= out =~ ANALYZE_FAILED ? true : false
|
|
182
|
+
require_fail ||= out =~ REQUIRE_CALL_FAIL ? true : false
|
|
183
|
+
hard_error ||= out =~ HARD_COMPILE_ERROR ? true : false
|
|
143
184
|
out.scan(UNRESOLVED_CALL) { |m| calls << m[0] }
|
|
144
185
|
out.scan(UNRESOLVED_REQUIRE) { |m| requires << m[0] }
|
|
145
186
|
end
|
|
146
187
|
end
|
|
188
|
+
# A compile that failed ONLY because a plain `require` is unresolvable
|
|
189
|
+
# (no real codegen error, no other unsupported call) is the documented
|
|
190
|
+
# no-load-path limitation, not a failure of the gem's own code.
|
|
191
|
+
require_only_fail = !exit_ok && !timed_out && require_fail && !hard_error && calls.empty?
|
|
147
192
|
{ calls: calls.uniq, requires: requires.uniq,
|
|
148
|
-
analyze_failed: analyze_failed, exit_ok: exit_ok, timed_out: timed_out
|
|
193
|
+
analyze_failed: analyze_failed, exit_ok: exit_ok, timed_out: timed_out,
|
|
194
|
+
require_only_fail: require_only_fail }
|
|
149
195
|
end
|
|
150
196
|
|
|
151
197
|
# Run `spinel -c FILE -o OUT_C` with a wall-clock cap. Returns
|
data/lib/bundler/spinel/site.rb
CHANGED
|
@@ -52,7 +52,7 @@ module Bundler
|
|
|
52
52
|
|
|
53
53
|
# One-line semantics per verdict — used as the lede on each per-verdict page.
|
|
54
54
|
BLURB = {
|
|
55
|
-
"verified" => "<strong>Full surface</strong> compiles and a behaviour smoke matches CRuby under a Spinel-compiled harness — every <code>lib/</code> file force-required (no <code>autoload</code> masking, no missing-dependency rescue), not just the entrypoint. The only verdict to trust where it matters. A constant/VERSION-only smoke that loads the entrypoint but leaves the gem's real code behind <code>autoload</code> is <em>not</em> enough — that overstated usability, so the bar was tightened to whole-surface. Sticky across engine revisions until a re-run catches a regression.",
|
|
55
|
+
"verified" => "<strong>Full surface</strong> compiles and a behaviour smoke matches CRuby under a Spinel-compiled harness — every <code>lib/</code> file force-required (no <code>autoload</code> masking, no missing-dependency rescue), not just the entrypoint. The only verdict to trust where it matters. A constant/VERSION-only smoke that loads the entrypoint but leaves the gem's real code behind <code>autoload</code> is <em>not</em> enough — that overstated usability, so the bar was tightened to whole-surface. Sticky across engine revisions until a re-run catches a regression. <strong>Or</strong>: for a gem the mechanical probe can't rank — a Spinel-<em>native</em> program rather than a <code>require</code>-library (e.g. <code>tep</code>, the translator that compiles this very site) — a <span class=\"badge human\">👤 human</span> attestation of real production use is the verification (the strongest signal we carry); a fresh behaviour failure still overrides it.",
|
|
56
56
|
"loaded" => "Compiles and loads identically under CRuby and Spinel via a require-only differential. Logic untested — a gem can load fine and still silently miscompile in the code paths the require-only smoke doesn't exercise. Weaker than <strong>verified</strong>; not a trust signal.",
|
|
57
57
|
"clean" => "Compiles clean (cheap static lower bound). No behaviour was exercised — the survey doesn't run the gem. Massively overstates compatibility; the harness is the trustworthy check.",
|
|
58
58
|
"risky" => "Compiles, but the source uses constructs Spinel degrades silently (<code>eval</code>, <code>define_method</code>, …). Allowed by default; fails under <code>spinel-compat check --strict</code>.",
|
|
@@ -313,6 +313,17 @@ module Bundler
|
|
|
313
313
|
current_entries[v.gem] << v if v.rev == target_rev
|
|
314
314
|
end
|
|
315
315
|
|
|
316
|
+
# A human attestation of real production use is the strongest trust
|
|
317
|
+
# signal we carry — stronger than a hand smoke — and for a gem the
|
|
318
|
+
# mechanical probe *can't* rank, it's the only applicable verification.
|
|
319
|
+
# The require-probe assumes "gem = library you require"; a Spinel-native
|
|
320
|
+
# program like tep (a translator/framework, FFI + no CRuby runtime) can't
|
|
321
|
+
# pass it though it demonstrably works (it compiles this very site). So a
|
|
322
|
+
# human-attested (gem,version) earns ★ exactly like a verify-full pass —
|
|
323
|
+
# unless a fresh *behaviour* probe (verify/verify-full) contradicts the
|
|
324
|
+
# human's claim (handled by the `behaviour_rejected` guard below).
|
|
325
|
+
attestations.each_key { |k| ever_verified << k }
|
|
326
|
+
|
|
316
327
|
current_entries.map do |name, vs|
|
|
317
328
|
# Within the current rev, pick the *strongest* signal — not the
|
|
318
329
|
# most recent. A rejected (compile error or harness miscompile)
|
|
@@ -403,6 +414,12 @@ module Bundler
|
|
|
403
414
|
# candidates.tsv / compat.jsonl for machine consumers).
|
|
404
415
|
def verdict_page_html(verdict, all_rs, counts)
|
|
405
416
|
full = all_rs.select { |r| r.verdict == verdict }
|
|
417
|
+
# Signals-first in every tier: a gem with a human attestation and/or
|
|
418
|
+
# passing own-tests outranks an unsignaled one (most-trusted on top),
|
|
419
|
+
# then by downloads — matching the dynamic /catalog order_by. all_rs is
|
|
420
|
+
# already downloads-sorted, so the stable sort keeps downloads as the
|
|
421
|
+
# tiebreak. Keeps signal-bearing low-download gems (e.g. tep) at the top.
|
|
422
|
+
full = full.sort_by.with_index { |r, i| [-((r.human ? 1 : 0) + (r.tests ? 1 : 0)), i] }
|
|
406
423
|
capped = (verdict == "rejected") && full.size > REJECTED_CAP
|
|
407
424
|
shown = capped ? full.first(REJECTED_CAP) : full
|
|
408
425
|
|
|
@@ -424,7 +441,7 @@ module Bundler
|
|
|
424
441
|
|
|
425
442
|
body << %(<div class="filters">\n)
|
|
426
443
|
body << %( <input id="q" type="search" placeholder="filter by gem name…" autocomplete="off">\n)
|
|
427
|
-
body << %( <label class="floor"><input type="checkbox" id="floor"
|
|
444
|
+
body << %( <label class="floor"><input type="checkbox" id="floor"> )
|
|
428
445
|
body << %(hide low-signal gems (< #{fmt_n MIN_DOWNLOADS} downloads)</label>\n)
|
|
429
446
|
body << %(</div>\n)
|
|
430
447
|
|
|
@@ -432,7 +449,7 @@ module Bundler
|
|
|
432
449
|
body << %(<th class="num">downloads</th><th>updated</th><th>description</th></tr></thead><tbody>\n)
|
|
433
450
|
shown.each do |r|
|
|
434
451
|
gem_cell = r.homepage ? %(<a href="#{h r.homepage}" rel="noopener nofollow">#{h r.gem}</a>) : h(r.gem)
|
|
435
|
-
body << %(<tr data-gem="#{h r.gem.downcase}" data-dl="#{r.downloads}">)
|
|
452
|
+
body << %(<tr data-gem="#{h r.gem.downcase}" data-dl="#{r.downloads}" data-sig="#{r.human || r.tests ? 1 : 0}">)
|
|
436
453
|
body << %(<td class="v #{r.verdict}" title="#{h r.notes}">#{GLYPH[r.verdict]} #{r.verdict}</td>)
|
|
437
454
|
body << %(<td class="sig">#{signals_html(r)}</td>)
|
|
438
455
|
body << %(<td class="g">#{gem_cell} <span class="ver">#{h r.version}</span></td>)
|
|
@@ -459,7 +476,7 @@ module Bundler
|
|
|
459
476
|
<body>
|
|
460
477
|
<header>
|
|
461
478
|
<a class="brand" href="/"><svg class="gem" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12l4 6-10 13L2 9z" fill="#7b2d8e"/><path d="M6 3 2 9l10 13z" fill="#5a1f6b" opacity=".55"/><path d="M18 3l4 6-10 13z" fill="#b14fc4"/><path d="M6 3h12l-6 6z" fill="#d98ee8"/></svg>SpinelGems</a>
|
|
462
|
-
<nav><a href="/">Home</a> <a href="/catalog">Catalog</a> <a href="/history.html">History</a>
|
|
479
|
+
<nav><a href="/">Home</a> <a href="/catalog">Catalog</a> <a href="/load-bearing.html">Load-bearing</a> <a href="/history.html">History</a>
|
|
463
480
|
<a href="https://github.com/OriPekelman/spinelgems">GitHub</a></nav>
|
|
464
481
|
</header>
|
|
465
482
|
<main>
|
|
@@ -528,7 +545,8 @@ module Bundler
|
|
|
528
545
|
const hideLow = floor.checked;
|
|
529
546
|
for (const tr of rows) {
|
|
530
547
|
const okQ = !term || tr.dataset.gem.includes(term);
|
|
531
|
-
|
|
548
|
+
// signal-bearing rows (👤/✪) are never hidden by the floor
|
|
549
|
+
const okF = !hideLow || tr.dataset.sig === '1' || (+tr.dataset.dl) >= FLOOR;
|
|
532
550
|
tr.style.display = (okQ && okF) ? '' : 'none';
|
|
533
551
|
}
|
|
534
552
|
}
|
|
@@ -29,28 +29,74 @@ module Bundler
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def vendor(lockfile = "Gemfile.lock", into: "vendor/spinel", warn_incompatible: true,
|
|
32
|
-
ext_overrides: {}, ext_disable: [])
|
|
32
|
+
ext_overrides: {}, ext_disable: [], ext_enable: [])
|
|
33
33
|
parsed = Bundler::LockfileParser.new(File.read(lockfile))
|
|
34
34
|
lock_dir = File.dirname(File.expand_path(lockfile))
|
|
35
35
|
into = File.expand_path(into)
|
|
36
36
|
FileUtils.mkdir_p(into)
|
|
37
37
|
disable = (ext_disable + ENV["SPINEL_EXT_DISABLE"].to_s.split(",")).map(&:strip).reject(&:empty?)
|
|
38
|
+
enable = (ext_enable + ENV["SPINEL_EXT_ENABLE"].to_s.split(",")).map(&:strip).reject(&:empty?)
|
|
39
|
+
# Per-run state for build-units: copy-once over shared source dirs
|
|
40
|
+
# (spinelgems#20) + the optional-entry names actually seen, so a typo'd
|
|
41
|
+
# --with-ext warns loud instead of silently doing nothing.
|
|
42
|
+
@copied_build_dirs = {}
|
|
43
|
+
@ext_names_seen = []
|
|
38
44
|
|
|
39
45
|
manifest = []
|
|
40
46
|
exts = 0
|
|
41
|
-
|
|
47
|
+
sig_root = File.join(into, "sig")
|
|
48
|
+
FileUtils.rm_rf(sig_root)
|
|
49
|
+
sig_gems = []
|
|
50
|
+
# Topological order (dependencies before dependents), not the lockfile's
|
|
51
|
+
# alphabetical `specs` (spinelgems#19): Spinel has no load path, so
|
|
52
|
+
# deps.rb is a *flattened single load* — each gem's entrypoint
|
|
53
|
+
# require_relative'd once, in order. A dependent loaded before its
|
|
54
|
+
# dependency would reference not-yet-defined constants. tep→spinel_kit
|
|
55
|
+
# is the first real case (it sorted right only by alphabetical luck).
|
|
56
|
+
topo_sort(parsed.specs).each do |spec|
|
|
42
57
|
name = spec.name
|
|
43
58
|
version = spec.version.to_s
|
|
44
59
|
src = resolve_source(spec, lock_dir)
|
|
45
60
|
dest = File.join(into, name)
|
|
46
61
|
place(src, dest)
|
|
47
|
-
exts += wire_extensions(src, dest, ext_overrides, disable)
|
|
48
|
-
|
|
62
|
+
exts += wire_extensions(src, dest, ext_overrides, disable, enable)
|
|
63
|
+
sig_gems << name if collect_sig(dest, sig_root, name)
|
|
64
|
+
if (target = require_target(name, dest))
|
|
65
|
+
manifest << { require: target, libdir: "#{File.basename(dest)}/lib" }
|
|
66
|
+
end
|
|
49
67
|
note_compat(name, version) if warn_incompatible
|
|
50
68
|
end
|
|
51
69
|
|
|
52
|
-
|
|
53
|
-
|
|
70
|
+
unless (unknown = enable - @ext_names_seen).empty?
|
|
71
|
+
warn "[vendor] --with-ext/SPINEL_EXT_ENABLE names matched no optional " \
|
|
72
|
+
"entry in any vendored manifest: #{unknown.join(', ')}"
|
|
73
|
+
end
|
|
74
|
+
write_manifest(into, manifest, sig_gems)
|
|
75
|
+
{ into: into, count: manifest.size, extensions: exts, sig_gems: sig_gems }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Order specs so every gem's runtime dependencies come before it — a DFS
|
|
79
|
+
# post-order over `spec.dependencies`, with an alphabetical tiebreak for
|
|
80
|
+
# determinism and a visiting-set guard so a dependency cycle degrades to
|
|
81
|
+
# *some* stable order instead of looping. Deps not present in this lockset
|
|
82
|
+
# (stdlib/default gems) are skipped. (spinelgems#19)
|
|
83
|
+
def topo_sort(specs)
|
|
84
|
+
by_name = specs.each_with_object({}) { |s, h| h[s.name] = s }
|
|
85
|
+
ordered = []
|
|
86
|
+
state = {} # name => :visiting | :done
|
|
87
|
+
visit = lambda do |spec|
|
|
88
|
+
st = state[spec.name]
|
|
89
|
+
return if st == :done || st == :visiting
|
|
90
|
+
state[spec.name] = :visiting
|
|
91
|
+
spec.dependencies.sort_by(&:name).each do |dep|
|
|
92
|
+
dn = dep.respond_to?(:name) ? dep.name : dep.to_s
|
|
93
|
+
visit.call(by_name[dn]) if by_name[dn]
|
|
94
|
+
end
|
|
95
|
+
state[spec.name] = :done
|
|
96
|
+
ordered << spec
|
|
97
|
+
end
|
|
98
|
+
specs.sort_by(&:name).each { |s| visit.call(s) }
|
|
99
|
+
ordered
|
|
54
100
|
end
|
|
55
101
|
|
|
56
102
|
# path:/git: lockfile sources (toy ↔ tep is the headline case)
|
|
@@ -76,12 +122,29 @@ module Bundler
|
|
|
76
122
|
def place(src, dest)
|
|
77
123
|
FileUtils.rm_rf(dest)
|
|
78
124
|
FileUtils.mkdir_p(dest)
|
|
79
|
-
|
|
125
|
+
# sig/ rides along with lib/: a gem's shipped *.rbs is its type root
|
|
126
|
+
# under Spinel (spinelgems#13) — verify --dir on the vendored copy
|
|
127
|
+
# auto-detects it, and vendor() aggregates one --rbs root from all gems.
|
|
128
|
+
%w[lib sig].each do |sub|
|
|
80
129
|
s = File.join(src, sub)
|
|
81
130
|
FileUtils.cp_r(s, dest) if File.directory?(s)
|
|
82
131
|
end
|
|
83
132
|
end
|
|
84
133
|
|
|
134
|
+
# Aggregate a vendored gem's sig/*.rbs under <into>/sig/<name>/ so the
|
|
135
|
+
# consumer build passes a single `--rbs <into>/sig` (the driver takes one
|
|
136
|
+
# DIR; spinel_rbs_extract walks it recursively). Without this, uncalled
|
|
137
|
+
# public methods in a library widen to int/poly — the seed-block failure
|
|
138
|
+
# mode spinelgems#13 retires. Returns true when the gem contributed sigs.
|
|
139
|
+
def collect_sig(dest, sig_root, name)
|
|
140
|
+
sig = File.join(dest, "sig")
|
|
141
|
+
return false if Dir[File.join(sig, "**", "*.rbs")].empty?
|
|
142
|
+
|
|
143
|
+
FileUtils.mkdir_p(sig_root)
|
|
144
|
+
FileUtils.cp_r(sig, File.join(sig_root, name))
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
|
|
85
148
|
# Build + wire any C extensions the gem declares in spinel-ext.json
|
|
86
149
|
# (OriPekelman/spinelgems#2; see docs/c-ext.md). Each entry substitutes its
|
|
87
150
|
# @PLACEHOLDER@ in the placed Ruby — so the gem's `ffi_cflags "@PLACEHOLDER@"`
|
|
@@ -93,8 +156,13 @@ module Bundler
|
|
|
93
156
|
# - any static `libs`.
|
|
94
157
|
# An `optional` entry the consumer opted out of (`name` in `disable` /
|
|
95
158
|
# SPINEL_EXT_DISABLE) substitutes its `disabled_cflags` instead (category C).
|
|
96
|
-
#
|
|
97
|
-
|
|
159
|
+
# An `optional` entry declaring `"default": "disabled"` is opt-IN
|
|
160
|
+
# (spinelgems#20 — CUDA/Metal units must not build, or fail to configure,
|
|
161
|
+
# on a default `vendor`): it is treated as opted-out unless the consumer
|
|
162
|
+
# enables it (`--with-ext NAME` / SPINEL_EXT_ENABLE). Explicit disable
|
|
163
|
+
# beats enable. Returns the count wired. A no-op for gems without the
|
|
164
|
+
# manifest.
|
|
165
|
+
def wire_extensions(src, dest, overrides, disable, enable = [])
|
|
98
166
|
manifest = File.join(src, "spinel-ext.json")
|
|
99
167
|
return 0 unless File.exist?(manifest)
|
|
100
168
|
|
|
@@ -111,23 +179,58 @@ module Bundler
|
|
|
111
179
|
sib_cflags = Hash.new { |h, k| h[k] = [] }
|
|
112
180
|
entries.each do |e|
|
|
113
181
|
next if e["source"] || !e["name"] # only CFLAGS-only siblings
|
|
114
|
-
next if e
|
|
182
|
+
next if ext_disabled?(e, disable, enable)
|
|
115
183
|
(cf = pkg_config_cflags(e)) && sib_cflags[e["name"].to_s].concat(cf)
|
|
116
184
|
sib_cflags[e["name"].to_s].concat(Array(e["cflags"])) if e["cflags"]
|
|
117
185
|
end
|
|
118
186
|
|
|
119
187
|
wired = 0
|
|
188
|
+
# name -> vendored build dir, for cross-entry {dir:NAME} link expansion
|
|
189
|
+
# (toy#45: tinynn's link line needs both its own dir and ggml's).
|
|
190
|
+
# Entries are processed in manifest order, so referenced units come first.
|
|
191
|
+
built_dirs = {}
|
|
120
192
|
entries.each do |e|
|
|
121
193
|
placeholder = e["placeholder"]
|
|
122
194
|
name = e["name"]
|
|
195
|
+
(@ext_names_seen ||= []) << name.to_s if name && e["optional"]
|
|
123
196
|
|
|
124
|
-
#
|
|
125
|
-
|
|
197
|
+
# Opted out — explicitly (`--no-ext`), or an opt-in (`"default":
|
|
198
|
+
# "disabled"`) entry the consumer didn't enable (spinelgems#20).
|
|
199
|
+
if ext_disabled?(e, disable, enable)
|
|
126
200
|
substitute_placeholder(dest, placeholder, e["disabled_cflags"].to_s) if placeholder
|
|
127
201
|
wired += 1
|
|
128
202
|
next
|
|
129
203
|
end
|
|
130
204
|
|
|
205
|
+
# Build-unit entry (spinelgems#14): a declared native build (cmake|make)
|
|
206
|
+
# producing archives *inside the consumer's vendor tree*, with `link`
|
|
207
|
+
# flags expanded relative to it ({dir} -> the vendored build dir). This
|
|
208
|
+
# is the heavy-native analogue of `source` per-.c entries — nokogiri's
|
|
209
|
+
# mini_portile2 precedent, Spinel-shaped. It replaces the per-consumer
|
|
210
|
+
# post-vendor absolute-path rewrite hooks (toy's prep/post_vendor_toy.rb)
|
|
211
|
+
# that made vendored trees non-relocatable and toy unpublishable.
|
|
212
|
+
# A consumer override (SPINEL_EXT_<PLACEHOLDER> / --ext) supplies the
|
|
213
|
+
# full replacement flags and skips the build (prebuilt escape hatch).
|
|
214
|
+
if e["build"]
|
|
215
|
+
if placeholder && (ov = overrides[placeholder] || ENV[ext_env_key(placeholder)])
|
|
216
|
+
substitute_placeholder(dest, placeholder, ov.to_s)
|
|
217
|
+
wired += 1
|
|
218
|
+
next
|
|
219
|
+
end
|
|
220
|
+
ven_dir = build_unit(src, dest, e) or next # build failed (warned)
|
|
221
|
+
built_dirs[name.to_s] = ven_dir if name
|
|
222
|
+
if placeholder
|
|
223
|
+
parts = Array(e["link"]).map do |t|
|
|
224
|
+
t.gsub("{dir}", ven_dir)
|
|
225
|
+
.gsub(/\{dir:([^}]+)\}/) { built_dirs[$1] || "{dir:#{$1}}" }
|
|
226
|
+
end
|
|
227
|
+
parts.concat(Array(e["libs"]))
|
|
228
|
+
substitute_placeholder(dest, placeholder, parts.join(" ").strip, name: name)
|
|
229
|
+
end
|
|
230
|
+
wired += 1
|
|
231
|
+
next
|
|
232
|
+
end
|
|
233
|
+
|
|
131
234
|
# Compile / place the .o (or take a prebuilt override path). Both forms
|
|
132
235
|
# need this; post-#1011 const-fold form skips the substitution below.
|
|
133
236
|
obj = nil
|
|
@@ -160,6 +263,17 @@ module Bundler
|
|
|
160
263
|
0
|
|
161
264
|
end
|
|
162
265
|
|
|
266
|
+
# Is this optional entry off for this run? Explicit disable always wins;
|
|
267
|
+
# an opt-in entry (`"default": "disabled"`, spinelgems#20) is off unless
|
|
268
|
+
# its name is in the enable set. `default` is only meaningful on an
|
|
269
|
+
# `optional` entry with a `name`.
|
|
270
|
+
def ext_disabled?(entry, disable, enable)
|
|
271
|
+
return false unless entry["optional"] && entry["name"]
|
|
272
|
+
n = entry["name"].to_s
|
|
273
|
+
return true if disable.include?(n)
|
|
274
|
+
entry["default"].to_s == "disabled" && !enable.include?(n)
|
|
275
|
+
end
|
|
276
|
+
|
|
163
277
|
# System libs/cflags for an entry, resolved at the consumer's environment:
|
|
164
278
|
# `pkg-config --cflags --libs <name>`, else `pkg_config_fallback`, else nil
|
|
165
279
|
# (leave the placeholder so the build fails loud — we never silently drop a
|
|
@@ -214,11 +328,180 @@ module Bundler
|
|
|
214
328
|
nil
|
|
215
329
|
end
|
|
216
330
|
|
|
217
|
-
|
|
331
|
+
# Build-unit (spinelgems#14): copy the gem's declared build dir into the
|
|
332
|
+
# vendor tree, run the declared tool there, verify the declared artifacts.
|
|
333
|
+
# Returns the vendored dir path (project-relative when `into` was given
|
|
334
|
+
# relative — the usual case — so substituted -L flags stay relocatable
|
|
335
|
+
# with the consumer project) or nil on failure (warned, entry skipped).
|
|
336
|
+
#
|
|
337
|
+
# The tool surface is deliberately constrained to cmake|make with declared
|
|
338
|
+
# args/targets/artifacts — no free-form shell. extconf.rb is precedent for
|
|
339
|
+
# arbitrary install-time code in gems, but there's no need to copy that
|
|
340
|
+
# mistake into spinel-ext.json: a declarative unit stays auditable and the
|
|
341
|
+
# detector-inferable, consumer-side philosophy survives.
|
|
342
|
+
def build_unit(src, dest, entry)
|
|
343
|
+
b = entry["build"]
|
|
344
|
+
dir_rel = b["dir"].to_s
|
|
345
|
+
src_dir = File.join(src, dir_rel)
|
|
346
|
+
unless File.directory?(src_dir)
|
|
347
|
+
warn "[vendor] build dir not found for #{entry['name'] || entry['placeholder']}: #{dir_rel}"
|
|
348
|
+
return nil
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
ven_dir = File.join(dest, dir_rel)
|
|
352
|
+
# Copy-once (spinelgems#20): two entries may share one source dir —
|
|
353
|
+
# toy's CPU and CUDA ggml units differ only in configure args/build_dir.
|
|
354
|
+
# The rm_rf+recopy below would wipe the first entry's just-built tree,
|
|
355
|
+
# so a dir already placed this vendor run is reused as-is. (Patches stay
|
|
356
|
+
# declarable on both entries: the stack-level detection no-ops on an
|
|
357
|
+
# already-patched copy.)
|
|
358
|
+
@copied_build_dirs ||= {}
|
|
359
|
+
unless @copied_build_dirs[ven_dir]
|
|
360
|
+
FileUtils.mkdir_p(File.dirname(ven_dir))
|
|
361
|
+
FileUtils.rm_rf(ven_dir)
|
|
362
|
+
# Source-only copy: a path:-sourced dev checkout carries build state —
|
|
363
|
+
# build*/ dirs (whose CMakeCache pins the ORIGINAL source path and makes
|
|
364
|
+
# cmake refuse the copy), the .patched sentinel, .git. ggml: 205MB with
|
|
365
|
+
# build dirs, 24MB without. Top-level name filter covers the real cases.
|
|
366
|
+
FileUtils.mkdir_p(ven_dir)
|
|
367
|
+
Dir.children(src_dir).each do |c|
|
|
368
|
+
next if c.start_with?("build") || c == ".git" || c == ".patched"
|
|
369
|
+
# stale dev objects/archives would also poison make's mtime logic
|
|
370
|
+
next if c =~ /\.(o|a|so|dylib|bundle)\z/
|
|
371
|
+
FileUtils.cp_r(File.join(src_dir, c), File.join(ven_dir, c))
|
|
372
|
+
end
|
|
373
|
+
@copied_build_dirs[ven_dir] = true
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Declared patches (toy#45: pristine vendored ggml + vendor-patches/*.patch),
|
|
377
|
+
# applied into the COPY before configure — mini_portile's patch_files
|
|
378
|
+
# precedent. Globs resolve against the gem root; patch files are data,
|
|
379
|
+
# which keeps the no-free-form-shell property of the schema.
|
|
380
|
+
# `git apply` (not patch(1)): strict, no fuzz — patch(1) happily
|
|
381
|
+
# *re*-applies hunks fuzzily onto an already-patched tree. Works in
|
|
382
|
+
# non-repo dirs too (gem-shipped trees have no .git).
|
|
383
|
+
#
|
|
384
|
+
# Patches form an ordered STACK (later ones rewrite earlier ones'
|
|
385
|
+
# hunks), so already-applied detection is stack-level, not per-patch:
|
|
386
|
+
# a pristine tree forward-applies the FIRST patch; a fully-patched
|
|
387
|
+
# working tree (path:-sourced dev checkout, toy's `.patched` flow)
|
|
388
|
+
# reverse-applies the LAST. Anything else is genuine drift — fail.
|
|
389
|
+
patches = Array(entry["build"]["patches"])
|
|
390
|
+
.flat_map { |g| Dir[File.join(src, g.to_s)].sort }
|
|
391
|
+
.map { |p| File.expand_path(p) }
|
|
392
|
+
unless patches.empty?
|
|
393
|
+
_, pristine = Open3.capture2e("git", "-C", ven_dir, "apply", "--check", patches.first)
|
|
394
|
+
if pristine.success?
|
|
395
|
+
patches.each do |abs|
|
|
396
|
+
out, st = Open3.capture2e("git", "-C", ven_dir, "apply", abs)
|
|
397
|
+
unless st.success?
|
|
398
|
+
warn "[vendor] patch failed (#{entry['name']}): #{File.basename(abs)}: #{out.lines.last(2).join.strip}"
|
|
399
|
+
return nil
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
else
|
|
403
|
+
_, stacked = Open3.capture2e("git", "-C", ven_dir, "apply", "--reverse", "--check", patches.last)
|
|
404
|
+
unless stacked.success?
|
|
405
|
+
warn "[vendor] patches for #{entry['name']} neither apply (pristine) nor " \
|
|
406
|
+
"reverse-apply (already patched) — source tree drifted from the patch set"
|
|
407
|
+
return nil
|
|
408
|
+
end
|
|
409
|
+
# already-patched working tree: nothing to do
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
jobs = begin
|
|
414
|
+
require "etc"
|
|
415
|
+
Etc.nprocessors.to_s
|
|
416
|
+
rescue StandardError
|
|
417
|
+
"4"
|
|
418
|
+
end
|
|
419
|
+
cmds =
|
|
420
|
+
case b["tool"].to_s
|
|
421
|
+
when "cmake"
|
|
422
|
+
# Variant build dirs over a shared source (spinelgems#20): toy's
|
|
423
|
+
# CUDA unit configures the same vendored ggml into build-cuda/
|
|
424
|
+
# alongside the CPU unit's build/. Relative, no escape.
|
|
425
|
+
bd = (b["build_dir"] || "build").to_s
|
|
426
|
+
if bd.start_with?("/") || bd.split(File::SEPARATOR).include?("..")
|
|
427
|
+
warn "[vendor] bad build_dir for #{entry['name']}: #{bd.inspect} (relative, no ..)"
|
|
428
|
+
return nil
|
|
429
|
+
end
|
|
430
|
+
build_dir = File.join(ven_dir, bd)
|
|
431
|
+
cfg = ["cmake", "-S", ven_dir, "-B", build_dir, *Array(b["args"]).map(&:to_s)]
|
|
432
|
+
bld = ["cmake", "--build", build_dir, "-j", jobs]
|
|
433
|
+
targets = Array(b["targets"]).map(&:to_s)
|
|
434
|
+
bld.push("--target", *targets) unless targets.empty?
|
|
435
|
+
[cfg, bld]
|
|
436
|
+
when "make"
|
|
437
|
+
[["make", "-C", ven_dir, "-j", jobs,
|
|
438
|
+
*Array(b["args"]).map(&:to_s), *Array(b["targets"]).map(&:to_s)]]
|
|
439
|
+
else
|
|
440
|
+
warn "[vendor] unknown build tool #{b['tool'].inspect} for #{entry['name']} (cmake|make)"
|
|
441
|
+
return nil
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
env = build_env(b["tool"].to_s)
|
|
445
|
+
cmds.each do |cmd|
|
|
446
|
+
out, st = Open3.capture2e(env, *cmd)
|
|
447
|
+
unless st.success?
|
|
448
|
+
warn "[vendor] build failed (#{entry['name']}): #{cmd.take(2).join(' ')} ... : " \
|
|
449
|
+
"#{out.lines.last(3).join.strip}"
|
|
450
|
+
return nil
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
missing = Array(b["artifacts"]).reject { |a| File.exist?(File.join(ven_dir, a.to_s)) }
|
|
455
|
+
unless missing.empty?
|
|
456
|
+
warn "[vendor] build for #{entry['name']} succeeded but artifacts missing: #{missing.join(', ')}"
|
|
457
|
+
return nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Project-relative {dir} when the vendor tree lives under the consumer's
|
|
461
|
+
# cwd (the normal `--into vendor/spinel` case) — substituted -L flags
|
|
462
|
+
# then survive moving the whole project, not just deleting the gem's
|
|
463
|
+
# source checkout. Compile from the project root (the documented flow).
|
|
464
|
+
pwd = Dir.pwd + File::SEPARATOR
|
|
465
|
+
ven_dir.start_with?(pwd) ? ven_dir.delete_prefix(pwd) : ven_dir
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Build-unit environment (spinelgems#21): on macOS, a cmake unit with C++
|
|
469
|
+
# sources can't find the stdlib headers (`fatal error: 'array' file not
|
|
470
|
+
# found`) unless CPLUS_INCLUDE_PATH carries the SDK's libc++ path —
|
|
471
|
+
# toy#27's Mac cold-start LIB leg, verified fix on the M2. Tool-side, not
|
|
472
|
+
# per-gem env: the platform knowledge belongs here, or every gem with a
|
|
473
|
+
# C++ unit repeats it. A caller-set CPLUS_INCLUDE_PATH is preserved (the
|
|
474
|
+
# SDK path appends); xcrun absent/failing degrades to no env, and the
|
|
475
|
+
# build then fails with the original loud cmake error.
|
|
476
|
+
def build_env(tool, platform: RUBY_PLATFORM)
|
|
477
|
+
return {} unless tool == "cmake" && platform.include?("darwin")
|
|
478
|
+
sdk, st = Open3.capture2e("xcrun", "--show-sdk-path")
|
|
479
|
+
sdk = sdk.strip
|
|
480
|
+
return {} unless st.success? && !sdk.empty?
|
|
481
|
+
libcxx = File.join(sdk, "usr", "include", "c++", "v1")
|
|
482
|
+
paths = [ENV["CPLUS_INCLUDE_PATH"].to_s, libcxx].reject(&:empty?)
|
|
483
|
+
{ "CPLUS_INCLUDE_PATH" => paths.join(File::PATH_SEPARATOR) }
|
|
484
|
+
rescue Errno::ENOENT
|
|
485
|
+
{}
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# A placeholder that substitutes ZERO files is drift (toy#45: a gem whose
|
|
489
|
+
# ffi_cflags line moved out from under its manifest's literal-string
|
|
490
|
+
# placeholder) — warn loud. This replaces per-gem canary hacks like toy's
|
|
491
|
+
# CURRENT_FFI_CFLAGS lockstep constant with a systemic vendor-time check.
|
|
492
|
+
def substitute_placeholder(dest, placeholder, repl, name: nil)
|
|
493
|
+
hits = 0
|
|
218
494
|
Dir[File.join(dest, "**", "*.rb")].each do |f|
|
|
219
495
|
body = File.read(f)
|
|
220
|
-
|
|
496
|
+
next unless body.include?(placeholder)
|
|
497
|
+
File.write(f, body.gsub(placeholder, repl))
|
|
498
|
+
hits += 1
|
|
499
|
+
end
|
|
500
|
+
if hits.zero?
|
|
501
|
+
warn "[vendor] #{name || 'ext'}: placeholder matched NO vendored .rb — " \
|
|
502
|
+
"manifest drift? (#{placeholder.length > 60 ? placeholder[0, 57] + '...' : placeholder})"
|
|
221
503
|
end
|
|
504
|
+
hits
|
|
222
505
|
end
|
|
223
506
|
|
|
224
507
|
# @TEP_SPHTTP_O@ -> SPINEL_EXT_TEP_SPHTTP_O
|
|
@@ -248,10 +531,27 @@ module Bundler
|
|
|
248
531
|
"— may not compile (run `spinel-compat check`)"
|
|
249
532
|
end
|
|
250
533
|
|
|
251
|
-
def write_manifest(into,
|
|
534
|
+
def write_manifest(into, entries, sig_gems = [])
|
|
535
|
+
es = entries.compact
|
|
252
536
|
body = +"# Generated by bundler-spinel. require_relative this from a\n" \
|
|
253
|
-
"# Spinel program to pull in vendored dependencies (
|
|
254
|
-
|
|
537
|
+
"# Spinel program to pull in vendored dependencies (topo order:\n" \
|
|
538
|
+
"# every gem's dependencies are loaded before it — spinelgems#19).\n"
|
|
539
|
+
unless sig_gems.empty?
|
|
540
|
+
body << "# Type roots: #{sig_gems.join(', ')} ship sig/*.rbs, aggregated\n" \
|
|
541
|
+
"# under sig/ — compile with `--rbs #{File.join(into, 'sig')}`\n" \
|
|
542
|
+
"# so uncalled public methods keep their declared types (spinelgems#13).\n"
|
|
543
|
+
end
|
|
544
|
+
# Put each vendored gem's lib root on $LOAD_PATH so a dependent's plain
|
|
545
|
+
# `require "<depgem>"` resolves under CRuby too (the differential verify
|
|
546
|
+
# and plain-Ruby dev runs). Spinel has no load path: it ignores both the
|
|
547
|
+
# $LOAD_PATH lines and the inter-gem `require`, and instead loads
|
|
548
|
+
# everything via the topo-ordered require_relatives below — so the same
|
|
549
|
+
# deps.rb is correct for both runtimes. (spinelgems#19, gap 2)
|
|
550
|
+
es.map { |e| e[:libdir] }.uniq.each do |d|
|
|
551
|
+
body << %{$LOAD_PATH.unshift(File.expand_path(#{d.inspect}, __dir__))\n}
|
|
552
|
+
end
|
|
553
|
+
body << "\n"
|
|
554
|
+
es.each { |e| body << %{require_relative "#{e[:require]}"\n} }
|
|
255
555
|
File.write(File.join(into, "deps.rb"), body)
|
|
256
556
|
end
|
|
257
557
|
end
|
|
@@ -37,13 +37,20 @@ module Bundler
|
|
|
37
37
|
# vocabulary is unchanged; the probe is tagged `verify-full` so the
|
|
38
38
|
# whole-surface signal stays distinguishable in the ledger from the
|
|
39
39
|
# entrypoint-only `verify`.
|
|
40
|
-
|
|
40
|
+
# rbs: a gem's shipped sig/*.rbs acts as the type root for the Spinel
|
|
41
|
+
# compile (spinelgems#13). `--rbs` re-pins *uncalled* public methods'
|
|
42
|
+
# param/return/ivar types that whole-program inference would otherwise
|
|
43
|
+
# widen to int/poly for lack of a call site — the failure mode hand-written
|
|
44
|
+
# seed blocks exist to patch. :auto (default) uses <dir>/sig when it
|
|
45
|
+
# contains .rbs files; a String is an explicit root; nil/false disables.
|
|
46
|
+
def verify(gem_name, version, dir, smoke: nil, full: false, rbs: :auto)
|
|
41
47
|
@engine.ensure!
|
|
48
|
+
rbs_root = resolve_rbs_root(dir, rbs)
|
|
42
49
|
harness = File.join(dir, HARNESS)
|
|
43
50
|
File.write(harness, harness_source(gem_name, dir, smoke, full))
|
|
44
51
|
|
|
45
52
|
ruby_out, ruby_err, ruby_ok = run_ruby(harness, dir)
|
|
46
|
-
spin_out, spin_err, spin_ok = run_spinel(harness)
|
|
53
|
+
spin_out, spin_err, spin_ok = run_spinel(harness, rbs_root)
|
|
47
54
|
|
|
48
55
|
verdict, reasons = classify(ruby_ok, spin_ok, ruby_out, spin_out, spin_err, behavior: !smoke.nil?)
|
|
49
56
|
# Tag *why* (the spinelgems#4 usability rubric) so a failure says what it'd
|
|
@@ -57,9 +64,14 @@ module Bundler
|
|
|
57
64
|
# and, when it pins a diverging scalar, appends `localized:<file>:<line>
|
|
58
65
|
# <var> cruby=… spinel=…`. Best-effort: nil when it can't localize.
|
|
59
66
|
if verdict == "rejected" && reasons.include?("miscompile")
|
|
67
|
+
# Note: the bisector compiles without the sig root, so localization of
|
|
68
|
+
# an rbs-seeded build is best-effort (bisect.sh has no --rbs passthrough).
|
|
60
69
|
loc = Localizer.new(@engine).localize(harness)
|
|
61
70
|
reasons += [loc] if loc
|
|
62
71
|
end
|
|
72
|
+
# Provenance: the verdict was reached with the gem's sig/ as type root —
|
|
73
|
+
# legible in the ledger/notes, ignored by the site's sticky logic.
|
|
74
|
+
reasons += ["rbs:sig"] if rbs_root
|
|
63
75
|
@ledger.record(@ledger.build(
|
|
64
76
|
gem: gem_name, version: version, rev: @engine.rev,
|
|
65
77
|
verdict: verdict, reasons: reasons, probe: full ? "verify-full" : "verify"
|
|
@@ -180,9 +192,18 @@ module Bundler
|
|
|
180
192
|
[out, err, st.success?]
|
|
181
193
|
end
|
|
182
194
|
|
|
183
|
-
def
|
|
195
|
+
def resolve_rbs_root(dir, rbs)
|
|
196
|
+
return nil if rbs.nil? || rbs == false
|
|
197
|
+
return File.expand_path(rbs) if rbs.is_a?(String)
|
|
198
|
+
sig = File.join(dir, "sig")
|
|
199
|
+
Dir[File.join(sig, "**", "*.rbs")].empty? ? nil : sig
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def run_spinel(file, rbs_root = nil)
|
|
184
203
|
bin = file.sub(/\.rb$/, ".bin")
|
|
185
|
-
|
|
204
|
+
args = [@engine.bin, file, "-o", bin]
|
|
205
|
+
args += ["--rbs", rbs_root] if rbs_root
|
|
206
|
+
_, cerr, cst = Open3.capture3(*args)
|
|
186
207
|
return ["", cerr, false] unless cst.success? && File.executable?(bin)
|
|
187
208
|
|
|
188
209
|
out, err, st = Open3.capture3(bin)
|