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.
@@ -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
- /\bThread\.(new|start|fork)\b/ => "Thread.new",
58
- /\bMutex\.new\b/ => "Mutex.new",
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
@@ -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" checked> )
444
+ body << %( <label class="floor"><input type="checkbox" id="floor"> )
428
445
  body << %(hide low-signal gems (&lt; #{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
- const okF = !hideLow || (+tr.dataset.dl) >= FLOOR;
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
- parsed.specs.each do |spec|
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
- manifest << require_target(name, dest)
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
- write_manifest(into, manifest)
53
- { into: into, count: manifest.size, extensions: exts }
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
- %w[lib].each do |sub|
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
- # Returns the count wired. A no-op for gems without the manifest.
97
- def wire_extensions(src, dest, overrides, disable)
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["optional"] && disable.include?(e["name"].to_s)
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
- # Opt-out (only meaningful with a placeholder to write disabled_cflags into).
125
- if e["optional"] && name && disable.include?(name.to_s)
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
- def substitute_placeholder(dest, placeholder, repl)
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
- File.write(f, body.gsub(placeholder, repl)) if body.include?(placeholder)
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, targets)
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 (lock order).\n"
254
- targets.compact.each { |t| body << %{require_relative "#{t}"\n} }
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
- def verify(gem_name, version, dir, smoke: nil, full: false)
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 run_spinel(file)
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
- _, cerr, cst = Open3.capture3(@engine.bin, file, "-o", bin)
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)