Class: Bundler::Spinel::Probe
- Inherits:
-
Object
- Object
- Bundler::Spinel::Probe
- Defined in:
- lib/bundler/spinel/probe.rb
Overview
Decides a compatibility verdict for an unpacked gem against the current Spinel engine. Two complementary signals, because Spinel’s failure modes are not exit codes:
1. COMPILE SIGNAL. Spinel never exits non-zero on unsupported Ruby; it
emits `warning: ... cannot resolve call to 'X' ... (emitting 0)` and
degrades the call to a no-op. So we compile the gem's lib entrypoints
with `spinel -c` and parse stderr. An unresolved-call warning names
the exact missing feature (`unresolved:eval`), which is what makes the
verdict forward-compatible — re-probing under a newer engine clears it
the moment Spinel learns that call.
2. STATIC RISK SIGNAL. Some constructs (define_method, instance_eval, a
bare `send`) are degraded with *no* warning at all, and dead-code
elimination can hide an unsupported call that's defined-but-uncalled
in a library. So we also scan the source for known-risky tokens. These
don't reject on their own (the gem may never hit that path) but they
downgrade `clean` → `risky`.
NEITHER signal catches Spinel’s *silent miscompiles* (local-var-name collapse, Int-0-as-nil). Only the ‘verified` rung — running the gem’s own tests through a Spinel-compiled harness — does. The lock-time gate trusts ‘clean`; only a curated whitelist / platform variant trusts `verified`.
Constant Summary collapse
- UNRESOLVED_CALL =
An unsupported call — the strongest, most precise signal, and the one that’s forward-compatible (names the exact feature Spinel lacks today).
/cannot resolve call to '([^']+)'/.freeze
- UNRESOLVED_REQUIRE =
A ‘require “x”` Spinel couldn’t follow. Spinel has no load path (plain require resolves only against <spinel>/lib), so a gem’s own split files and stdlib deps surface here. Informational, NOT a standalone reject: it’s as often a probe limitation as a real incompatibility.
/require "([^"]+)" could not be resolved/.freeze
- ANALYZE_FAILED =
/\b(analyze failed|fatal)\b/i.freeze
- COMPILE_TIMEOUT =
Spinel’s analyze pass can spin for minutes on pathological inputs (no internal bound). In a wholesale survey that’s indistinguishable from a hang, so we cap each compile and treat an overrun as its own reject reason — ‘analyze-timeout` is itself a useful roadmap signal (which gems blow up the analyzer). Override with SPINEL_COMPILE_TIMEOUT (seconds).
Integer(ENV.fetch("SPINEL_COMPILE_TIMEOUT", "60"))
- HARD_REJECT_TOKENS =
Tokens whose mere presence in ‘lib/*/.rb` makes the gem a definite Spinel reject — there’s no path under which the compile would succeed, so we skip the (expensive) spinel call entirely and record a ‘rejected` verdict from the static scan alone. Conservative set: only constructs Spinel will never support (threads, Mutex, TracePoint). Metaprogramming tokens like `define_method` stay in RISK_TOKENS below — they degrade silently, so the compile signal is still the right call there.
{ /\bThread\.(new|start|fork)\b/ => "Thread.new", /\bMutex\.new\b/ => "Mutex.new", /\bMutex_m\b/ => "Mutex_m", /\bTracePoint\b/ => "TracePoint" }.freeze
- RISK_TOKENS =
token => reason. Tokens Spinel cannot honour and may silently no-op.
{ /\beval\s*\(/ => "eval", /\binstance_eval\b/ => "instance_eval", /\b(class|module)_eval\b/ => "class_eval", /\bdefine_method\b/ => "define_method", /\bmethod_missing\b/ => "method_missing", /\brespond_to_missing\?/ => "respond_to_missing", /\bconst_missing\b/ => "const_missing", /\.send\s*\(/ => "send", /\bpublic_send\b/ => "public_send", /\bObjectSpace\b/ => "objectspace", /\b(TracePoint|set_trace_func)\b/ => "tracepoint", /\bbinding\b/ => "binding" }.freeze
Instance Method Summary collapse
-
#initialize(engine, ledger) ⇒ Probe
constructor
A new instance of Probe.
-
#probe(gem_name, version, dir) ⇒ Object
gem_name, version, dir(unpacked) -> recorded Ledger::Verdict.
Constructor Details
#initialize(engine, ledger) ⇒ Probe
Returns a new instance of Probe.
79 80 81 82 |
# File 'lib/bundler/spinel/probe.rb', line 79 def initialize(engine, ledger) @engine = engine @ledger = ledger end |
Instance Method Details
#probe(gem_name, version, dir) ⇒ Object
gem_name, version, dir(unpacked) -> recorded Ledger::Verdict
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
# File 'lib/bundler/spinel/probe.rb', line 85 def probe(gem_name, version, dir) @engine.ensure! # Fail fast on cheap static signals before spending the spinel-compile # budget — no entrypoint, a C extension, or a top-level token Spinel # will never support → record `rejected` and skip the compile. if (fast = static_hard_reject(dir, gem_name)) return @ledger.record(@ledger.build( gem: gem_name, version: version, rev: @engine.rev, verdict: "rejected", reasons: fast, risks: [], probe: "static" )) end sig = compile_signal(dir, gem_name) risks = static_signal(dir) # Unfollowed requires are notes, not rejections — record them so a human # can see when a verdict is entangled with the no-load-path limitation. risks += sig[:requires].map { |r| "needs:#{r}" } verdict, reasons = if !sig[:calls].empty? # Genuine unsupported call(s): unambiguous, forward-compatible reject. ["rejected", sig[:calls].map { |s| "unresolved:#{s}" }] elsif sig[:timed_out] # Analyzer ran past the cap — pathological for Spinel, not the gem. ["rejected", ["analyze-timeout"]] elsif sig[:analyze_failed] || !sig[:exit_ok] ["rejected", ["analyze-failed"]] elsif !static_only_risks(risks).empty? ["risky", []] else ["clean", []] end @ledger.record(@ledger.build( gem: gem_name, version: version, rev: @engine.rev, verdict: verdict, reasons: reasons.uniq, risks: risks.uniq, probe: "compile+scan" )) end |