Class: Bundler::Spinel::Probe

Inherits:
Object
  • Object
show all
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

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