bundler-spinel 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5a1d4be2ef6216a5cd1241e7ef27dd6ee5222f79f21e6924b8c52c0fe02f35b
4
- data.tar.gz: 0a36f37f19a5333e42515a23ec80524ee99bd2d81322e573bb7b3376b4a231b9
3
+ metadata.gz: f60b276e969f601d5931d3df6b6e7177820f58609d18e1082e49784e4acf7ce3
4
+ data.tar.gz: 9a5b89828d86ab07cacbb3d85a63ff7167d0cecc83e96138ca4de28e19c51083
5
5
  SHA512:
6
- metadata.gz: e9fd87e3769ca2246b536ef82c4e6c5c6a704021183008516ce7b41ebf10f0e310d03e297b06887c86cb9f97dc8ae388c6f45f01ce92f1a1a7af791763af0af9
7
- data.tar.gz: '0894f3ed497a5b7c2e226d5ec5b86ce8b60872775d12c72090df8a6da3f8eead78a93b8ced7e994769e0359a48850be22526192683351a1b16dfe49714744013'
6
+ metadata.gz: 60290945a8663c019299e28553af1c5bbd6766f8606a1b8675142653b1cd2692eae7e3eb9d7d243fbbfa05765c58f44eee74fb9ebb1852606bf2ecff9ce7ec9b
7
+ data.tar.gz: fb8b90c9cec7bda764f34625fd3643c27facb676c9a951551fd2a53a1c365e782acd0bd7d318a21c50062ea37c9f41c3105761ab01974d87fa51bfc329a90102
data/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ All notable changes to `bundler-spinel` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
5
5
  project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.0] — 2026-06-08
8
+
9
+ ### Added
10
+ - **`spinel-compat vendor` build-units (spinelgems#14).** `spinel-ext.json` now
11
+ supports a `build` entry — a declared native build (`tool: cmake | make`, with
12
+ `dir`, `args`, `targets`, `artifacts`, and `patches`) run **inside the
13
+ consumer's vendor tree**, with link flags expanded relative to it (`{dir}` and
14
+ cross-entry `{dir:NAME}`). This lets a heavy-native gem — toy's vendored ggml
15
+ CMake build plus its tinynn shims — vendor **self-contained and relocatable**,
16
+ the same end state tep's small per-`.c` shims already had. A
17
+ `SPINEL_EXT_<PLACEHOLDER>` override substitutes prebuilt flags and skips the
18
+ build. Declared `patches` apply with stack-level already-applied detection, so
19
+ a `path:`-sourced dev checkout (already patched) vendors cleanly. Proven
20
+ end-to-end: a consumer vendors toy, the archives build in-tree, a
21
+ Spinel-compiled program links and runs, with zero absolute paths and survival
22
+ across a project move. Strictly narrower than `extconf.rb` (no free-form
23
+ shell; declared artifacts) — the Spinel analogue of a gemspec `extensions:`.
24
+ - **`spinel-compat vendor` handles transitive gem→gem dependencies
25
+ (spinelgems#19).** Vendoring a gem that depends on another vendored gem now
26
+ works: `deps.rb` is emitted in **topological order** (every gem's runtime
27
+ dependencies load before it, via a DFS over `spec.dependencies` with a stable
28
+ alphabetical tiebreak and a cycle guard) instead of the lockfile's alphabetical
29
+ order, and it prepends each vendored gem's `lib` to `$LOAD_PATH` so a
30
+ dependent's plain `require "<depgem>"` resolves under CRuby too. Spinel (no load
31
+ path) ignores both and relies on the topo-ordered `require_relative`s, so the
32
+ one `deps.rb` is correct under both runtimes — verified identical output on a
33
+ two-gem fixture. Unblocks `tep` → `spinel_kit` (the new stdlib-surface gem)
34
+ on the clean `gem "spinel_kit"` + vendor path.
35
+ - **`spinel-compat why <gem>` (spinelgems#12).** A legible "why doesn't this gem
36
+ work (yet)?" report: a plain-English cause, a category (native C-ext / Spinel
37
+ limitation / fixable compiler bug / dependency-blocked / metaprogramming), the
38
+ concrete evidence (the CRuby-vs-Spinel diff, the unresolved calls, the missing
39
+ require, the hard construct), and — most usefully — whether the verdict is
40
+ **terminal** (needs an upstream port or compiler feature) or **fixable** (a
41
+ tracked compiler bug that can graduate). Reads the dominant-rev ledger entry
42
+ like the catalog; `--probe` / `--dir` explains a fresh live probe instead.
43
+
44
+ ### Changed
45
+ - **`spinel-ext.json` wiring now warns on manifest drift.** A declared
46
+ placeholder that matches no vendored `.rb` emits a loud warning at vendor time
47
+ — replacing the per-gem "cflags canary" constants consumers maintained by hand.
48
+ - **Catalog/site rendering** advanced across several engine-rev reprobes (signals
49
+ lead every verdict tier; the download floor is off by default; human
50
+ attestations earn a verified rung; a load-bearing-gems roadmap page). These
51
+ affect the rendered spinelgems.org catalog, not the plugin's gating behaviour.
52
+
7
53
  ## [0.2.1] — 2026-06-01
8
54
 
9
55
  ### Fixed
@@ -21,6 +21,7 @@ module Bundler
21
21
  when "install-engine" then cmd_install_engine(argv)
22
22
  when "init" then cmd_init(argv)
23
23
  when "probe" then cmd_probe(argv)
24
+ when "why" then cmd_why(argv)
24
25
  when "verify" then cmd_verify(argv)
25
26
  when "vendor" then cmd_vendor(argv)
26
27
  when "check" then cmd_check(argv)
@@ -29,6 +30,7 @@ module Bundler
29
30
  when "build-site" then cmd_build_site(argv)
30
31
  when "build-db" then cmd_build_db(argv)
31
32
  when "build-history" then cmd_build_history(argv)
33
+ when "build-load-bearing" then cmd_build_load_bearing(argv)
32
34
  when "server" then cmd_server(argv)
33
35
  when "ledger" then cmd_ledger(argv)
34
36
  when "diff" then cmd_diff(argv)
@@ -91,6 +93,54 @@ module Bundler
91
93
  v.rejected? ? 1 : 0
92
94
  end
93
95
 
96
+ # why NAME [VERSION] [--rev REV] [--probe] [--dir PATH]
97
+ # Legible "why doesn't this gem work (yet)?" report (spinelgems#12).
98
+ # Reads the recorded verdict (dominant rev, like the catalog) by default;
99
+ # --probe / --dir explains a fresh live probe instead.
100
+ def cmd_why(argv)
101
+ require_relative "why"
102
+ dir = (i = argv.index("--dir")) ? argv.delete_at(i + 1).tap { argv.delete_at(i) } : nil
103
+ rev = (k = argv.index("--rev")) ? argv.delete_at(k + 1).tap { argv.delete_at(k) } : nil
104
+ live = !!argv.delete("--probe") || !!dir
105
+ name = argv.shift or raise Error, "usage: spinel-compat why NAME [VERSION] [--rev REV] [--probe] [--dir PATH]"
106
+ version = argv.shift
107
+
108
+ if live
109
+ engine = Engine.new
110
+ src = dir ? File.expand_path(dir) : GemFetcher.new.fetch(name, version || latest_version(name))
111
+ v = Probe.new(engine, Ledger.new).probe(name, version || "path", src)
112
+ Why.new(out: @out).report(v, source: "live probe")
113
+ else
114
+ v = ledger_pick(name, version: version, rev: rev)
115
+ unless v
116
+ @out.puts " no recorded verdict for #{name}#{version ? " #{version}" : ''}. " \
117
+ "Run `spinel-compat why #{name} --probe` to evaluate it now."
118
+ return 1
119
+ end
120
+ Why.new(out: @out).report(v, source: "ledger #{v.rev}")
121
+ end
122
+ 0
123
+ end
124
+
125
+ # The authoritative ledger entry for a gem: a specific --rev if asked,
126
+ # else the entry at the ledger's dominant rev (the rev most rows share —
127
+ # the same target the catalog renders), else the most recent seen.
128
+ def ledger_pick(name, version: nil, rev: nil)
129
+ entries = []
130
+ rev_counts = Hash.new(0)
131
+ Ledger.new.each do |v|
132
+ rev_counts[v.rev] += 1
133
+ next unless v.gem == name
134
+ next if version && v.version != version
135
+ entries << v
136
+ end
137
+ return nil if entries.empty?
138
+ return entries.select { |v| v.rev == rev }.last if rev
139
+
140
+ target = rev_counts.max_by { |_, c| c }&.first
141
+ entries.select { |v| v.rev == target }.last || entries.last
142
+ end
143
+
94
144
  def cmd_verify(argv)
95
145
  dir = (i = argv.index("--dir")) ? argv.delete_at(i + 1).tap { argv.delete_at(i) } : nil
96
146
  smoke = (j = argv.index("--smoke")) ? argv.delete_at(j + 1).tap { argv.delete_at(j) } : nil
@@ -308,6 +358,13 @@ module Bundler
308
358
  0
309
359
  end
310
360
 
361
+ def cmd_build_load_bearing(argv)
362
+ out = (j = argv.index("--out")) ? argv[j + 1] : raise(Error, "build-load-bearing needs --out FILE")
363
+ f = LoadBearing.new.build_html(File.expand_path(out))
364
+ @out.puts "built load-bearing -> #{f}"
365
+ 0
366
+ end
367
+
311
368
  # Serve the static site + (with --store) the Compact Index from one process
312
369
  # — what the deploy host runs. Port defaults to $PORT (Upsun) then 9292.
313
370
  def cmd_server(argv)
@@ -410,6 +467,7 @@ module Bundler
410
467
  spinel-compat install-engine [REV] fetch+build the Spinel compiler -> ~/.cache/spinel
411
468
  spinel-compat init [DIR] scaffold a Spinel+Tep project (Gemfile, app.rb, bin/build)
412
469
  spinel-compat probe NAME [VERSION] probe one gem, record a verdict
470
+ spinel-compat why NAME [--probe] legible "why doesn't this work (yet)?" report
413
471
  spinel-compat verify NAME [--smoke F] differential CRuby-vs-Spinel run -> verified
414
472
  spinel-compat vendor [LOCK] [--into D] place deps where Spinel finds them + deps.rb
415
473
  spinel-compat check [LOCK] [--strict] gate a Gemfile.lock (exit 1 if rejected)
@@ -26,6 +26,63 @@ module Bundler
26
26
  "(<code>e2e010c</code>); together with the new <code>instance_methods</code> const-fold " \
27
27
  "(<a href=\"https://github.com/matz/spinel/issues/1073\">#1073</a>) and <code>transpose</code>/map " \
28
28
  "specializations, the brass cluster and thousands more moved out of <code>rejected</code>." },
29
+ { rev: "95557f5", date: "2026-06-02", commit: "module/class-body side effects + lexical const refs (#1256), Regexp.last_match(n) (#1257), preserve Float-in-Hash (#1258), Struct typing, JSON.generate, alias, +14 more",
30
+ file: "survey-95557f5/compat.jsonl",
31
+ note: "<strong>The biggest single jump yet.</strong> 22 upstream commits — including fixes for three " \
32
+ "issues this harness filed (<a href=\"https://github.com/matz/spinel/issues/1256\">#1256</a> module-body, " \
33
+ "<a href=\"https://github.com/matz/spinel/issues/1257\">#1257</a> <code>Regexp.last_match</code>, " \
34
+ "<a href=\"https://github.com/matz/spinel/issues/1258\">#1258</a> Float-in-Hash) plus Struct typing, " \
35
+ "<code>alias</code>, <code>JSON.generate</code> for records and more — moved <strong>20,175</strong> gems " \
36
+ "out of <code>rejected</code>, among them <code>rspec</code>, <code>globalid</code>, " \
37
+ "<code>mini_portile2</code> and <code>coffee-rails</code>." },
38
+ { rev: "a782696", date: "2026-06-03", commit: "StringScanner unscan/check + Error, Time#to_s + puts-nil, Dir.exist? + alias_method dispatch, missing int-hash keys as nil, RBS extractor heterogeneous-union→poly, subclass-initialize poly unification (13 commits)",
39
+ file: "survey-a782696/compat.jsonl",
40
+ note: "<strong>A consolidation rev.</strong> The base verdict mix is essentially flat after the previous " \
41
+ "jump — 13 upstream commits of correctness fixes (<code>StringScanner</code>, <code>Time#to_s</code>, " \
42
+ "<code>Dir.exist?</code>/<code>alias_method</code>, and the RBS-extractor union→poly change) graduated a " \
43
+ "small set of gems — <code>google-adwords-api</code>, <code>libdatadog</code>, <code>random_user_agent</code>, " \
44
+ "<code>twitter_username_extractor</code> and the <code>redcar-*</code> cluster — while a couple regressed " \
45
+ "and were caught by the re-probe. The bigger story this rev was off the catalog: the harness found " \
46
+ "<code>spinel_analyze</code> consuming 100+ GB on a cluster of auto-generated API-SDK gems (a compiler " \
47
+ "memory blow-up, filed upstream)." },
48
+ { rev: "9c0a5f0", date: "2026-06-04", commit: "79 commits — incl. fixes for 6 harness-filed issues: stdlib-class-in-ivar (#1305), reopen-Object (#1306), lambda/proc branch-local (#1315), &blk+block_given? (#1316), inject(&:sym) (#1317), ignored-require constant (#1273)",
49
+ file: "survey-9c0a5f0/compat.jsonl",
50
+ note: "<strong>The harness loop paying off.</strong> matz landed fixes for <strong>six</strong> issues this " \
51
+ "harness filed the day before — all common idioms: <code>block_given?</code> with a named " \
52
+ "<code>&amp;blk</code> (<a href=\"https://github.com/matz/spinel/issues/1316\">#1316</a>), " \
53
+ "<code>inject(&amp;:+)</code> (<a href=\"https://github.com/matz/spinel/issues/1317\">#1317</a>), " \
54
+ "reopening <code>class Object</code> (<a href=\"https://github.com/matz/spinel/issues/1306\">#1306</a>), " \
55
+ "a stdlib class held in an instance variable " \
56
+ "(<a href=\"https://github.com/matz/spinel/issues/1305\">#1305</a>), and a branch-assigned local inside a " \
57
+ "lambda/proc (<a href=\"https://github.com/matz/spinel/issues/1315\">#1315</a>). <strong>3,487</strong> gems " \
58
+ "moved out of <code>rejected</code> (110.3k→106.8k). The one feature ruled out of scope — aliasing the " \
59
+ "regexp special globals (<a href=\"https://github.com/matz/spinel/issues/1307\">#1307</a>) — now fails with a " \
60
+ "clear diagnostic instead of bad C." },
61
+ { rev: "5c9790c", date: "2026-06-05", commit: "17 commits — fixes for 3 harness-filed typed-collection issues: Hash#fetch on int_int_hash (#1329), Array#join on poly_array (#1332), Class-in-collection→poly (#1337); plus regex line-anchoring/gsub-buffer + first-class string type",
62
+ file: "survey-5c9790c/compat.jsonl",
63
+ note: "<strong>Typed-collection coverage.</strong> matz fixed three issues this harness filed hours earlier — all " \
64
+ "the same shape: a method that exists on the generic path but was missing on a <em>specialized</em> " \
65
+ "collection. <code>Hash#fetch</code> on an int→int hash " \
66
+ "(<a href=\"https://github.com/matz/spinel/issues/1329\">#1329</a>), <code>Array#join</code> on a mixed " \
67
+ "<code>poly_array</code> (<a href=\"https://github.com/matz/spinel/issues/1332\">#1332</a>), and a " \
68
+ "<code>Class</code> value stored in a Hash/Array now typed as poly instead of int " \
69
+ "(<a href=\"https://github.com/matz/spinel/issues/1337\">#1337</a>, which had broken every options-hash " \
70
+ "carrying an exception class). The compile+scan base barely moves on fixes like these — they're " \
71
+ "full-surface/runtime, so the graduation shows in the behaviour-verified tier." },
72
+ { rev: "57af7f9", date: "2026-06-07", commit: "~40 commits — 9 harness-filed issues closed in one wave: the ecosystem-spine front doors (alias→attr_reader #1356, &:sym-after-positional parse #1359), unary operator mangling (#1357), sp_sym_intern link (#1355), plus typed-collection nil steps (#801/#1180) and #line / --emit-symbol-map diagnostics (the #1338 RFC direction)",
73
+ file: "survey-57af7f9/compat.jsonl",
74
+ note: "<strong>The spine-gems wave.</strong> Auditing why <code>bundler</code>/<code>rake</code>/" \
75
+ "<code>minitest</code>/<code>thor</code> reject found two shallow front doors — " \
76
+ "<code>alias</code> to an <code>attr_reader</code>-generated method " \
77
+ "(<a href=\"https://github.com/matz/spinel/issues/1356\">#1356</a>, rake + thor) and " \
78
+ "<code>&amp;:sym</code> after a positional argument mis-parsed as a hash literal " \
79
+ "(<a href=\"https://github.com/matz/spinel/issues/1359\">#1359</a>, bundler + minitest) — and matz closed " \
80
+ "both within a day, alongside 7 more harness filings. All four spine gems now compile past their old " \
81
+ "blockers into distinct second-tier issues " \
82
+ "(<a href=\"https://github.com/matz/spinel/issues/1368\">#1368</a> et al.). C compile errors now map back " \
83
+ "to Ruby source lines via <code>#line</code>, on by default — the " \
84
+ "<a href=\"https://github.com/matz/spinel/issues/1338\">#1338</a> RFC direction. The behaviour-verified " \
85
+ "tier reached 144 mechanical ★ this run." },
29
86
  ].freeze
30
87
 
31
88
  ORDER = %w[clean risky rejected].freeze
@@ -133,7 +190,7 @@ module Bundler
133
190
  <title>#{h title}</title><link rel="stylesheet" href="/assets/style.css"></head>
134
191
  <body>
135
192
  <header><a class="brand" href="/">#{gem}SpinelGems</a>
136
- <nav><a href="/">Home</a> <a href="/catalog">Catalog</a> <a href="/history.html">History</a>
193
+ <nav><a href="/">Home</a> <a href="/catalog">Catalog</a> <a href="/load-bearing.html">Load-bearing</a> <a href="/history.html">History</a>
137
194
  <a href="https://github.com/OriPekelman/spinelgems">GitHub</a></nav></header>
138
195
  <main>
139
196
  #{body}
@@ -0,0 +1,116 @@
1
+ require "cgi"
2
+ require_relative "site"
3
+
4
+ module Bundler
5
+ module Spinel
6
+ # The "build-it-first" roadmap page: which gems, if made to compile, unblock
7
+ # the most of the ecosystem. Reads the committed
8
+ # harness/load-bearing/targets.tsv (transitive load-bearing + buildability
9
+ # impact, precomputed from the local dependency graph) and renders a clear,
10
+ # status-coloured table. Static, committed in site/ like the history page.
11
+ class LoadBearing
12
+ DATA = File.expand_path("../../../harness/load-bearing/targets.tsv", __dir__)
13
+ GLYPH = Site::GLYPH
14
+
15
+ # Buildability snapshot @ 95557f5 (from harness/load-bearing/buildability.rb).
16
+ BUILDABLE = 50_688
17
+ BLOCKED = 29_139
18
+ REJECTED = 110_256
19
+
20
+ def initialize(data = DATA) = (@data = data)
21
+
22
+ def build_html(out)
23
+ rows = File.exist?(@data) ? File.readlines(@data)[1..].map { |l| l.chomp.split("\t") } : []
24
+ compiler = rows.select { |r| r[7] == "compiler" }.sort_by { |r| -r[1].to_i }
25
+ File.write(out, page("Load-bearing gems — SpinelGems", body(compiler, rows)))
26
+ out
27
+ end
28
+
29
+ private
30
+
31
+ def body(compiler, all)
32
+ b = +""
33
+ b << "<h1>Load-bearing gems</h1>\n"
34
+ b << %(<p class="lede">A gem matters to the ecosystem by how many gems pull it in )
35
+ b << %(<em>transitively</em> — directly, or as a dependency of a dependency, turtles all )
36
+ b << %(the way down. If Spinel can't compile a load-bearing gem, nothing above it can ship )
37
+ b << %(either. This is the build-it-first roadmap.</p>\n)
38
+
39
+ b << %(<div class="stat-row">\n)
40
+ b << stat("buildable", BUILDABLE, "whole dependency tree compiles")
41
+ b << stat("blocked", BLOCKED, "compiles itself — but a dependency is rejected")
42
+ b << stat("rejected", REJECTED, "doesn't compile")
43
+ b << %(</div>\n)
44
+ b << %(<p class="note">~<strong>#{fmt(BLOCKED)}</strong> gems compile on their own but )
45
+ b << %(can't actually be used because something beneath them is rejected. Fixing a load-bearing )
46
+ b << %(blocker flows <em>up</em> the tree — its dependents become buildable too.</p>\n)
47
+
48
+ b << %(<h2>Build-it-first targets</h2>\n)
49
+ b << %(<p class="sub">Ranked by <strong>impact</strong> — how many blocked gems become )
50
+ b << %(buildable if this one alone is fixed. Filtered to <strong>compiler-fixable</strong> )
51
+ b << %(failures: the native (C-extension) and heavy-metaprogramming (Rails-shaped) clusters )
52
+ b << %(are deliberately set aside as not the first target here. Each row carries two ways to )
53
+ b << %(fix it — a focused Spinel issue, or, for a small library, a PR to the gem itself.</p>\n)
54
+
55
+ b << %(<table id="catalog"><thead><tr>)
56
+ b << %(<th class="num">impact</th><th>gem</th><th>status</th><th class="num">load-bearing</th>)
57
+ b << %(<th>failure</th><th class="num">lib size</th><th>fix</th></tr></thead><tbody>\n)
58
+ compiler.first(120).each do |r|
59
+ gem, sole, _reach, transit, _dl, verdict, ftype, _ach, files, loc = r
60
+ fi = files.to_i
61
+ strat = fi.positive? && fi <= 12 ? %(<span class="badge human">lib PR</span> small — #{fi} files) :
62
+ %(<span class="badge rubric">Spinel issue</span>)
63
+ b << %(<tr>)
64
+ b << %(<td class="num"><strong>#{fmt sole}</strong></td>)
65
+ b << %(<td class="g">#{h gem}</td>)
66
+ b << %(<td class="v #{verdict}">#{GLYPH[verdict] || '?'} #{h verdict}</td>)
67
+ b << %(<td class="num">#{fmt transit}</td>)
68
+ b << %(<td><span class="badge rubric">#{h ftype}</span></td>)
69
+ b << %(<td class="num">#{fi.positive? ? "#{fi}f / #{loc}L" : "—"}</td>)
70
+ b << %(<td>#{strat}</td>)
71
+ b << %(</tr>\n)
72
+ end
73
+ b << "</tbody></table>\n"
74
+
75
+ nat = all.count { |r| r[7] == "native" }; mp = all.count { |r| r[7] == "metaprog" }
76
+ b << %(<p class="meta">Set aside as not-first-target: <strong>#{nat}</strong> native )
77
+ b << %(C-extension blockers (need FFI/ext vendoring) and <strong>#{mp}</strong> heavy-metaprogramming )
78
+ b << %(blockers (the Rails ecosystem). Method + data: )
79
+ b << %(<a href="https://github.com/OriPekelman/spinelgems/blob/main/docs/load-bearing-gems.md">docs/load-bearing-gems.md</a> · )
80
+ b << %(<a href="https://github.com/OriPekelman/spinelgems/blob/main/harness/load-bearing/">harness/load-bearing/</a>. )
81
+ b << %(Built locally from the gem cache's dependency graph; impact is per engine revision.</p>\n)
82
+ b
83
+ end
84
+
85
+ def stat(cls, n, label)
86
+ %( <div class="stat #{cls}"><b>#{fmt n}</b><span>#{label}</span></div>\n)
87
+ end
88
+
89
+ def fmt(n)
90
+ n = n.to_i
91
+ n >= 1000 ? "#{(n / 1000.0).round(1)}k" : n.to_s
92
+ end
93
+
94
+ def h(s) = CGI.escapeHTML(s.to_s)
95
+
96
+ def page(title, body)
97
+ gem = %(<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>)
98
+ <<~HTML
99
+ <!doctype html>
100
+ <html lang="en"><head><meta charset="utf-8">
101
+ <meta name="viewport" content="width=device-width, initial-scale=1">
102
+ <title>#{h title}</title><link rel="stylesheet" href="/assets/style.css"></head>
103
+ <body>
104
+ <header><a class="brand" href="/">#{gem}SpinelGems</a>
105
+ <nav><a href="/">Home</a> <a href="/catalog">Catalog</a> <a href="/load-bearing.html">Load-bearing</a>
106
+ <a href="/history.html">History</a> <a href="https://github.com/OriPekelman/spinelgems">GitHub</a></nav></header>
107
+ <main>
108
+ #{body}
109
+ </main>
110
+ #{Site::FOOTER_HTML}
111
+ </body></html>
112
+ HTML
113
+ end
114
+ end
115
+ end
116
+ end
@@ -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
  }
@@ -38,14 +38,22 @@ module Bundler
38
38
 
39
39
  manifest = []
40
40
  exts = 0
41
- parsed.specs.each do |spec|
41
+ # Topological order (dependencies before dependents), not the lockfile's
42
+ # alphabetical `specs` (spinelgems#19): Spinel has no load path, so
43
+ # deps.rb is a *flattened single load* — each gem's entrypoint
44
+ # require_relative'd once, in order. A dependent loaded before its
45
+ # dependency would reference not-yet-defined constants. tep→spinel_kit
46
+ # is the first real case (it sorted right only by alphabetical luck).
47
+ topo_sort(parsed.specs).each do |spec|
42
48
  name = spec.name
43
49
  version = spec.version.to_s
44
50
  src = resolve_source(spec, lock_dir)
45
51
  dest = File.join(into, name)
46
52
  place(src, dest)
47
53
  exts += wire_extensions(src, dest, ext_overrides, disable)
48
- manifest << require_target(name, dest)
54
+ if (target = require_target(name, dest))
55
+ manifest << { require: target, libdir: "#{File.basename(dest)}/lib" }
56
+ end
49
57
  note_compat(name, version) if warn_incompatible
50
58
  end
51
59
 
@@ -53,6 +61,30 @@ module Bundler
53
61
  { into: into, count: manifest.size, extensions: exts }
54
62
  end
55
63
 
64
+ # Order specs so every gem's runtime dependencies come before it — a DFS
65
+ # post-order over `spec.dependencies`, with an alphabetical tiebreak for
66
+ # determinism and a visiting-set guard so a dependency cycle degrades to
67
+ # *some* stable order instead of looping. Deps not present in this lockset
68
+ # (stdlib/default gems) are skipped. (spinelgems#19)
69
+ def topo_sort(specs)
70
+ by_name = specs.each_with_object({}) { |s, h| h[s.name] = s }
71
+ ordered = []
72
+ state = {} # name => :visiting | :done
73
+ visit = lambda do |spec|
74
+ st = state[spec.name]
75
+ return if st == :done || st == :visiting
76
+ state[spec.name] = :visiting
77
+ spec.dependencies.sort_by(&:name).each do |dep|
78
+ dn = dep.respond_to?(:name) ? dep.name : dep.to_s
79
+ visit.call(by_name[dn]) if by_name[dn]
80
+ end
81
+ state[spec.name] = :done
82
+ ordered << spec
83
+ end
84
+ specs.sort_by(&:name).each { |s| visit.call(s) }
85
+ ordered
86
+ end
87
+
56
88
  # path:/git: lockfile sources (toy ↔ tep is the headline case)
57
89
  # point at a local tree; we don't go through `gem fetch`. For GEM
58
90
  # sources we fall back to the cache-backed RubyGems fetcher.
@@ -117,6 +149,10 @@ module Bundler
117
149
  end
118
150
 
119
151
  wired = 0
152
+ # name -> vendored build dir, for cross-entry {dir:NAME} link expansion
153
+ # (toy#45: tinynn's link line needs both its own dir and ggml's).
154
+ # Entries are processed in manifest order, so referenced units come first.
155
+ built_dirs = {}
120
156
  entries.each do |e|
121
157
  placeholder = e["placeholder"]
122
158
  name = e["name"]
@@ -128,6 +164,35 @@ module Bundler
128
164
  next
129
165
  end
130
166
 
167
+ # Build-unit entry (spinelgems#14): a declared native build (cmake|make)
168
+ # producing archives *inside the consumer's vendor tree*, with `link`
169
+ # flags expanded relative to it ({dir} -> the vendored build dir). This
170
+ # is the heavy-native analogue of `source` per-.c entries — nokogiri's
171
+ # mini_portile2 precedent, Spinel-shaped. It replaces the per-consumer
172
+ # post-vendor absolute-path rewrite hooks (toy's prep/post_vendor_toy.rb)
173
+ # that made vendored trees non-relocatable and toy unpublishable.
174
+ # A consumer override (SPINEL_EXT_<PLACEHOLDER> / --ext) supplies the
175
+ # full replacement flags and skips the build (prebuilt escape hatch).
176
+ if e["build"]
177
+ if placeholder && (ov = overrides[placeholder] || ENV[ext_env_key(placeholder)])
178
+ substitute_placeholder(dest, placeholder, ov.to_s)
179
+ wired += 1
180
+ next
181
+ end
182
+ ven_dir = build_unit(src, dest, e) or next # build failed (warned)
183
+ built_dirs[name.to_s] = ven_dir if name
184
+ if placeholder
185
+ parts = Array(e["link"]).map do |t|
186
+ t.gsub("{dir}", ven_dir)
187
+ .gsub(/\{dir:([^}]+)\}/) { built_dirs[$1] || "{dir:#{$1}}" }
188
+ end
189
+ parts.concat(Array(e["libs"]))
190
+ substitute_placeholder(dest, placeholder, parts.join(" ").strip, name: name)
191
+ end
192
+ wired += 1
193
+ next
194
+ end
195
+
131
196
  # Compile / place the .o (or take a prebuilt override path). Both forms
132
197
  # need this; post-#1011 const-fold form skips the substitution below.
133
198
  obj = nil
@@ -214,11 +279,141 @@ module Bundler
214
279
  nil
215
280
  end
216
281
 
217
- def substitute_placeholder(dest, placeholder, repl)
282
+ # Build-unit (spinelgems#14): copy the gem's declared build dir into the
283
+ # vendor tree, run the declared tool there, verify the declared artifacts.
284
+ # Returns the vendored dir path (project-relative when `into` was given
285
+ # relative — the usual case — so substituted -L flags stay relocatable
286
+ # with the consumer project) or nil on failure (warned, entry skipped).
287
+ #
288
+ # The tool surface is deliberately constrained to cmake|make with declared
289
+ # args/targets/artifacts — no free-form shell. extconf.rb is precedent for
290
+ # arbitrary install-time code in gems, but there's no need to copy that
291
+ # mistake into spinel-ext.json: a declarative unit stays auditable and the
292
+ # detector-inferable, consumer-side philosophy survives.
293
+ def build_unit(src, dest, entry)
294
+ b = entry["build"]
295
+ dir_rel = b["dir"].to_s
296
+ src_dir = File.join(src, dir_rel)
297
+ unless File.directory?(src_dir)
298
+ warn "[vendor] build dir not found for #{entry['name'] || entry['placeholder']}: #{dir_rel}"
299
+ return nil
300
+ end
301
+
302
+ ven_dir = File.join(dest, dir_rel)
303
+ FileUtils.mkdir_p(File.dirname(ven_dir))
304
+ FileUtils.rm_rf(ven_dir)
305
+ # Source-only copy: a path:-sourced dev checkout carries build state —
306
+ # build*/ dirs (whose CMakeCache pins the ORIGINAL source path and makes
307
+ # cmake refuse the copy), the .patched sentinel, .git. ggml: 205MB with
308
+ # build dirs, 24MB without. Top-level name filter covers the real cases.
309
+ FileUtils.mkdir_p(ven_dir)
310
+ Dir.children(src_dir).each do |c|
311
+ next if c.start_with?("build") || c == ".git" || c == ".patched"
312
+ # stale dev objects/archives would also poison make's mtime logic
313
+ next if c =~ /\.(o|a|so|dylib|bundle)\z/
314
+ FileUtils.cp_r(File.join(src_dir, c), File.join(ven_dir, c))
315
+ end
316
+
317
+ # Declared patches (toy#45: pristine vendored ggml + vendor-patches/*.patch),
318
+ # applied into the COPY before configure — mini_portile's patch_files
319
+ # precedent. Globs resolve against the gem root; patch files are data,
320
+ # which keeps the no-free-form-shell property of the schema.
321
+ # `git apply` (not patch(1)): strict, no fuzz — patch(1) happily
322
+ # *re*-applies hunks fuzzily onto an already-patched tree. Works in
323
+ # non-repo dirs too (gem-shipped trees have no .git).
324
+ #
325
+ # Patches form an ordered STACK (later ones rewrite earlier ones'
326
+ # hunks), so already-applied detection is stack-level, not per-patch:
327
+ # a pristine tree forward-applies the FIRST patch; a fully-patched
328
+ # working tree (path:-sourced dev checkout, toy's `.patched` flow)
329
+ # reverse-applies the LAST. Anything else is genuine drift — fail.
330
+ patches = Array(entry["build"]["patches"])
331
+ .flat_map { |g| Dir[File.join(src, g.to_s)].sort }
332
+ .map { |p| File.expand_path(p) }
333
+ unless patches.empty?
334
+ _, pristine = Open3.capture2e("git", "-C", ven_dir, "apply", "--check", patches.first)
335
+ if pristine.success?
336
+ patches.each do |abs|
337
+ out, st = Open3.capture2e("git", "-C", ven_dir, "apply", abs)
338
+ unless st.success?
339
+ warn "[vendor] patch failed (#{entry['name']}): #{File.basename(abs)}: #{out.lines.last(2).join.strip}"
340
+ return nil
341
+ end
342
+ end
343
+ else
344
+ _, stacked = Open3.capture2e("git", "-C", ven_dir, "apply", "--reverse", "--check", patches.last)
345
+ unless stacked.success?
346
+ warn "[vendor] patches for #{entry['name']} neither apply (pristine) nor " \
347
+ "reverse-apply (already patched) — source tree drifted from the patch set"
348
+ return nil
349
+ end
350
+ # already-patched working tree: nothing to do
351
+ end
352
+ end
353
+
354
+ jobs = begin
355
+ require "etc"
356
+ Etc.nprocessors.to_s
357
+ rescue StandardError
358
+ "4"
359
+ end
360
+ cmds =
361
+ case b["tool"].to_s
362
+ when "cmake"
363
+ build_dir = File.join(ven_dir, "build")
364
+ cfg = ["cmake", "-S", ven_dir, "-B", build_dir, *Array(b["args"]).map(&:to_s)]
365
+ bld = ["cmake", "--build", build_dir, "-j", jobs]
366
+ targets = Array(b["targets"]).map(&:to_s)
367
+ bld.push("--target", *targets) unless targets.empty?
368
+ [cfg, bld]
369
+ when "make"
370
+ [["make", "-C", ven_dir, "-j", jobs,
371
+ *Array(b["args"]).map(&:to_s), *Array(b["targets"]).map(&:to_s)]]
372
+ else
373
+ warn "[vendor] unknown build tool #{b['tool'].inspect} for #{entry['name']} (cmake|make)"
374
+ return nil
375
+ end
376
+
377
+ cmds.each do |cmd|
378
+ out, st = Open3.capture2e(*cmd)
379
+ unless st.success?
380
+ warn "[vendor] build failed (#{entry['name']}): #{cmd.take(2).join(' ')} ... : " \
381
+ "#{out.lines.last(3).join.strip}"
382
+ return nil
383
+ end
384
+ end
385
+
386
+ missing = Array(b["artifacts"]).reject { |a| File.exist?(File.join(ven_dir, a.to_s)) }
387
+ unless missing.empty?
388
+ warn "[vendor] build for #{entry['name']} succeeded but artifacts missing: #{missing.join(', ')}"
389
+ return nil
390
+ end
391
+
392
+ # Project-relative {dir} when the vendor tree lives under the consumer's
393
+ # cwd (the normal `--into vendor/spinel` case) — substituted -L flags
394
+ # then survive moving the whole project, not just deleting the gem's
395
+ # source checkout. Compile from the project root (the documented flow).
396
+ pwd = Dir.pwd + File::SEPARATOR
397
+ ven_dir.start_with?(pwd) ? ven_dir.delete_prefix(pwd) : ven_dir
398
+ end
399
+
400
+ # A placeholder that substitutes ZERO files is drift (toy#45: a gem whose
401
+ # ffi_cflags line moved out from under its manifest's literal-string
402
+ # placeholder) — warn loud. This replaces per-gem canary hacks like toy's
403
+ # CURRENT_FFI_CFLAGS lockstep constant with a systemic vendor-time check.
404
+ def substitute_placeholder(dest, placeholder, repl, name: nil)
405
+ hits = 0
218
406
  Dir[File.join(dest, "**", "*.rb")].each do |f|
219
407
  body = File.read(f)
220
- File.write(f, body.gsub(placeholder, repl)) if body.include?(placeholder)
408
+ next unless body.include?(placeholder)
409
+ File.write(f, body.gsub(placeholder, repl))
410
+ hits += 1
221
411
  end
412
+ if hits.zero?
413
+ warn "[vendor] #{name || 'ext'}: placeholder matched NO vendored .rb — " \
414
+ "manifest drift? (#{placeholder.length > 60 ? placeholder[0, 57] + '...' : placeholder})"
415
+ end
416
+ hits
222
417
  end
223
418
 
224
419
  # @TEP_SPHTTP_O@ -> SPINEL_EXT_TEP_SPHTTP_O
@@ -248,10 +443,22 @@ module Bundler
248
443
  "— may not compile (run `spinel-compat check`)"
249
444
  end
250
445
 
251
- def write_manifest(into, targets)
446
+ def write_manifest(into, entries)
447
+ es = entries.compact
252
448
  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} }
449
+ "# Spinel program to pull in vendored dependencies (topo order:\n" \
450
+ "# every gem's dependencies are loaded before it — spinelgems#19).\n"
451
+ # Put each vendored gem's lib root on $LOAD_PATH so a dependent's plain
452
+ # `require "<depgem>"` resolves under CRuby too (the differential verify
453
+ # and plain-Ruby dev runs). Spinel has no load path: it ignores both the
454
+ # $LOAD_PATH lines and the inter-gem `require`, and instead loads
455
+ # everything via the topo-ordered require_relatives below — so the same
456
+ # deps.rb is correct for both runtimes. (spinelgems#19, gap 2)
457
+ es.map { |e| e[:libdir] }.uniq.each do |d|
458
+ body << %{$LOAD_PATH.unshift(File.expand_path(#{d.inspect}, __dir__))\n}
459
+ end
460
+ body << "\n"
461
+ es.each { |e| body << %{require_relative "#{e[:require]}"\n} }
255
462
  File.write(File.join(into, "deps.rb"), body)
256
463
  end
257
464
  end
@@ -1,9 +1,9 @@
1
1
  module Bundler
2
2
  module Spinel
3
- # First published release: the Gemfile convention + `spinel-compat vendor`
4
- # are proven, and `install-engine` now provisions the compiler too, so a
5
- # newcomer can `gem install bundler-spinel` and onboard without an
6
- # out-of-band Spinel build (spinelgems#9).
7
- VERSION = "0.2.1"
3
+ # 0.3.0: `spinel-compat vendor` grows build-units (cmake/make native deps
4
+ # built inside the consumer's vendor tree heavy-native gems like toy's
5
+ # ggml vendor self-contained + relocatable, #14), and a new
6
+ # `spinel-compat why <gem>` legible diagnostic (#12).
7
+ VERSION = "0.3.0"
8
8
  end
9
9
  end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module Spinel
5
+ # `spinel-compat why <gem>` — a legible "why doesn't this gem work (yet)?"
6
+ # report (spinelgems#12). Turns a recorded (or freshly probed) Verdict into
7
+ # plain English: the cause, a category (Spinel limitation vs fixable compiler
8
+ # bug vs native C-ext vs metaprogramming vs dependency-blocked), the specific
9
+ # evidence, and what it would take — including whether the verdict is
10
+ # TERMINAL (won't improve without an upstream/compiler change) or FIXABLE.
11
+ #
12
+ # The data is already in the ledger (the rubric tag, the spinel warnings
13
+ # distilled into `reasons`, the static `risks`); this assembles it instead
14
+ # of making a user grep C output. Where deeper localization helps, it points
15
+ # at `why --probe` (live compiler output) / spinel-dev doctor for deeper localization.
16
+ class Why
17
+ # rubric/risk/reason signal -> structured explanation. :terminal is
18
+ # :native — won't work without a Spinel-native port (terminal here)
19
+ # :limitation— a Spinel feature gap (improves when the compiler grows it)
20
+ # :bug — a fixable Spinel compiler bug (file/track upstream)
21
+ # :dep — blocked by a dependency (improves when the dep does)
22
+ # :ok — already usable; nothing to fix
23
+ EXPLAIN = {
24
+ "c-extension" => {
25
+ category: "native (C extension)", terminal: :native,
26
+ cause: "ships a C extension; Spinel is whole-program AOT and never dlopens a .so.",
27
+ take: "port the extension to Spinel's ffi_cflags/ffi_func DSL (tep/SpinelKit pattern), " \
28
+ "or consume a pure-Ruby alternative. The CRuby .so cannot be vendored.",
29
+ },
30
+ "needs-dep" => {
31
+ category: "dependency / not self-contained", terminal: :dep,
32
+ cause: "fails under CRuby in the harness too — it needs an external gem, TLS, or network the probe doesn't provide.",
33
+ take: "vendor the missing dependency (must itself be Spinel-compatible) or smoke only the offline surface.",
34
+ },
35
+ "load-path" => {
36
+ category: "Spinel limitation (load path)", terminal: :limitation,
37
+ cause: %(Spinel ignored a plain `require "gem/part"` (it has no load path), so the gem's real classes never compiled.),
38
+ take: "restructure the gem to require_relative its own files (Spinel inlines those), or wait on load-path support.",
39
+ },
40
+ "needs-stdlib" => {
41
+ category: "Spinel limitation (stdlib surface)", terminal: :limitation,
42
+ cause: "requires a standard-library feature Spinel doesn't ship.",
43
+ take: "use a Spinel-safe shim for that surface (SpinelKit consolidates these: JSON/Logger/…), or wait on stdlib coverage.",
44
+ },
45
+ "codegen" => {
46
+ category: "compiler bug (codegen)", terminal: :bug,
47
+ cause: "ordinary Ruby produced a C compile error — a fixable Spinel codegen bug, not a limitation of your code.",
48
+ take: "file/track a matz/spinel issue. `why <gem> --probe` shows the live compiler error; spinel-dev doctor " \
49
+ "localizes it to a file:line, and the harness usually has a minimal reproducer already.",
50
+ },
51
+ "miscompile" => {
52
+ category: "compiler bug (silent miscompile)", terminal: :bug,
53
+ cause: "it compiles and runs, but the output diverges from CRuby — the most dangerous failure, silently wrong.",
54
+ take: "file a matz/spinel issue with the diff below; spinel-dev doctor + value-bisection localize it to a file:line + variable.",
55
+ },
56
+ "unsupported" => {
57
+ category: "unsupported call (often metaprogramming)", terminal: :bug,
58
+ cause: "Spinel could not resolve a call and silently emitted 0 — typically dynamic dispatch (send/define_method/extend).",
59
+ take: "if it's a small codegen gap, file a matz/spinel issue; if it's deep metaprogramming, the surface is currently unsupported.",
60
+ },
61
+ "build-error" => {
62
+ category: "build/run error", terminal: :bug,
63
+ cause: "the Spinel build or run failed for a reason outside the other buckets.",
64
+ take: "inspect the reasons below; `why <gem> --probe` re-runs the compiler and surfaces the raw error line.",
65
+ },
66
+ "smoke-error" => {
67
+ category: "inconclusive (smoke broken under CRuby)", terminal: :dep,
68
+ cause: "the behaviour smoke didn't run cleanly under plain CRuby, so no Spinel conclusion can be drawn.",
69
+ take: "fix the smoke (a self-contained example of the gem's API), then re-verify.",
70
+ },
71
+ "analyze-oom" => {
72
+ category: "compiler bug (analyzer OOM)", terminal: :bug,
73
+ cause: "the Spinel analyzer exhausts memory on this gem (matz/spinel#1302); it's blacklisted from probing.",
74
+ take: "terminal until matz/spinel#1302 lands; tracked there with reproducers.",
75
+ },
76
+ # Static pre-filter rejections (probe=static): a hard construct found by
77
+ # source scan before any compile. Thread/Mutex compile now but misbehave
78
+ # (matz/spinel#1360); TracePoint/ObjectSpace are out of the AOT model.
79
+ "hard-construct" => {
80
+ category: "Spinel limitation (runtime construct)", terminal: :limitation,
81
+ cause: "uses a construct the static filter rejects before compiling (threads/mutexes/tracing).",
82
+ take: "Thread/Mutex are single-thread-degradable (matz/spinel#1360); TracePoint/ObjectSpace/set_trace_func " \
83
+ "are outside the closed-world AOT model. Often the gem works once that one construct is shimmed.",
84
+ },
85
+ }.freeze
86
+
87
+ POSITIVE = {
88
+ "verified" => "compiles, and a behaviour smoke runs identically under CRuby and a Spinel-compiled binary. " \
89
+ "Trustworthy — the only verdict that earns a curated-source slot.",
90
+ "loaded" => "compiles and loads identically to CRuby, but no behaviour smoke has exercised its logic at this rev — " \
91
+ "so a silent miscompile in that logic is still possible. Run `verify --smoke <file>` to lift it to verified.",
92
+ "clean" => "compiles clean and uses no dynamic constructs Spinel degrades — but it has only been compiled, not run. " \
93
+ "Run `verify` (require-only → loaded) or `verify --smoke` (behaviour → verified) to confirm it works.",
94
+ "risky" => "compiles, but the source uses dynamic constructs Spinel degrades silently — allowed by default, " \
95
+ "rejected under `check --strict`. Whether it actually works depends on whether those paths run; " \
96
+ "a behaviour smoke (`verify --smoke`) is the only way to know.",
97
+ }.freeze
98
+
99
+ USABLE = %w[verified loaded clean risky].freeze
100
+
101
+ def initialize(out: $stdout)
102
+ @out = out
103
+ end
104
+
105
+ # Render the report for a Verdict (from the ledger or a live probe).
106
+ def report(v, source: "ledger")
107
+ @out.puts
108
+ @out.puts "spinel-compat why #{v.gem} (#{v.version} @ #{v.rev || 'unknown rev'}, via #{source})"
109
+ @out.puts
110
+
111
+ glyph = { "verified" => "★", "loaded" => "○", "clean" => "✓", "risky" => "~", "rejected" => "✗" }[v.verdict] || "?"
112
+ line "verdict", "#{glyph} #{v.verdict}"
113
+
114
+ if USABLE.include?(v.verdict)
115
+ positive(v, glyph)
116
+ else
117
+ negative(v)
118
+ end
119
+ @out.puts
120
+ v
121
+ end
122
+
123
+ private
124
+
125
+ def positive(v, _glyph)
126
+ line "meaning", POSITIVE[v.verdict] if POSITIVE[v.verdict]
127
+ # risky/clean can still carry dynamic-construct risks worth surfacing.
128
+ dyn = dynamic_risks(v)
129
+ line "watch", "uses #{dyn.join(', ')} — Spinel may degrade these silently; a behaviour smoke confirms real behaviour." unless dyn.empty?
130
+ unmet = blocking_deps(v)
131
+ line "deps", "depends on #{unmet.join(', ')} (declared `needs:`) — vendor those too." unless unmet.empty?
132
+ end
133
+
134
+ def negative(v)
135
+ tag = rubric_tag(v) || static_tag(v)
136
+ info = EXPLAIN[tag] || EXPLAIN["build-error"]
137
+
138
+ line "category", info[:category]
139
+ line "cause", info[:cause]
140
+
141
+ detail = evidence(v, tag)
142
+ line "detail", detail unless detail.empty?
143
+
144
+ unmet = blocking_deps(v)
145
+ line "blocked-by", "rejected/unmet dependencies: #{unmet.join(', ')}" unless unmet.empty?
146
+
147
+ line "terminal?", terminal_line(info[:terminal])
148
+ line "what it'd take", info[:take]
149
+ end
150
+
151
+ # A static-probe rejection has no rubric tag; its reason IS the signal.
152
+ def static_tag(v)
153
+ reasons = v.reasons + v.risks
154
+ return "analyze-oom" if reasons.any? { |r| r.include?("analyze-oom") }
155
+ return "c-extension" if reasons.include?("c-extension")
156
+ return "hard-construct" if reasons.any? { |r| r.start_with?("hard:") }
157
+ return "unsupported" if reasons.any? { |r| r.start_with?("unresolved:") }
158
+ return "codegen" if reasons.any? { |r| r =~ /out\.c:\d+:\d+: *error:/ }
159
+ return "needs-dep" if reasons.any? && reasons.all? { |r| r.start_with?("needs:") }
160
+ nil
161
+ end
162
+
163
+ # --- signal extraction ---------------------------------------------------
164
+
165
+ def rubric_tag(v)
166
+ v.reasons.grep(/\Arubric:/).first&.sub("rubric:", "")
167
+ end
168
+
169
+
170
+ DYNAMIC = %w[define_method instance_eval class_eval module_eval eval send method_missing
171
+ const_set define_singleton_method].freeze
172
+
173
+ def dynamic_risks(v)
174
+ v.risks.select { |r| DYNAMIC.include?(r) || DYNAMIC.any? { |d| r.include?(d) } }.uniq
175
+ end
176
+
177
+ def blocking_deps(v)
178
+ (v.reasons + v.risks).grep(/\Aneeds:/).map { |r| r.sub("needs:", "") }.uniq
179
+ end
180
+
181
+ # The concrete evidence lines for a rejection, by tag.
182
+ def evidence(v, tag)
183
+ case tag
184
+ when "miscompile"
185
+ v.reasons.grep(/\Adiff:/).first.to_s.sub("diff:", "CRuby vs Spinel ").strip
186
+ when "unsupported"
187
+ calls = v.reasons.grep(/\Aunresolved:/).map { |r| r.sub("unresolved:", "") }
188
+ calls.empty? ? "" : "unresolved: #{calls.first(8).join(', ')}#{calls.size > 8 ? " (+#{calls.size - 8} more)" : ''}"
189
+ when "load-path", "needs-stdlib"
190
+ miss = v.reasons.grep(/could not be resolved|no .+\.rb/).first
191
+ miss ? miss.strip : ""
192
+ when "codegen", "build-error"
193
+ v.reasons.grep(/out\.c:|error:|fatal/i).first.to_s.strip
194
+ when "hard-construct"
195
+ v.reasons.grep(/\Ahard:/).map { |r| r.sub("hard:", "") }.join(", ")
196
+ when "c-extension"
197
+ "an ext/ directory with C/C++ sources (compiled by mkmf under CRuby)"
198
+ else
199
+ # fall back to the most informative non-rubric reason
200
+ v.reasons.reject { |r| r.start_with?("rubric:") }.first.to_s.strip
201
+ end
202
+ end
203
+
204
+ def terminal_line(kind)
205
+ case kind
206
+ when :native then "TERMINAL here — needs a Spinel-native port, not a catalog/compiler change."
207
+ when :limitation then "improves when Spinel grows the feature (a limitation, not a per-gem bug)."
208
+ when :bug then "FIXABLE — a compiler bug; the catalog tracks it and it can graduate on a Spinel fix."
209
+ when :dep then "conditional — unblocks when the dependency (or the smoke) is resolved."
210
+ else "—"
211
+ end
212
+ end
213
+
214
+ def line(label, text)
215
+ @out.puts format(" %-14s %s", label, text)
216
+ end
217
+ end
218
+ end
219
+ end
@@ -15,3 +15,4 @@ require_relative "spinel/vendorer"
15
15
  require_relative "spinel/survey"
16
16
  require_relative "spinel/checker"
17
17
  require_relative "spinel/engine_installer"
18
+ require_relative "spinel/load_bearing"
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundler-spinel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ori Pekelman
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-06-01 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
12
  description: A Bundler plugin + CLI that probes whether gems compile under Spinel
14
13
  and gates `bundle lock` on a forward-compatible, engine-rev-keyed compatibility
@@ -37,6 +36,7 @@ files:
37
36
  - lib/bundler/spinel/gem_fetcher.rb
38
37
  - lib/bundler/spinel/history.rb
39
38
  - lib/bundler/spinel/ledger.rb
39
+ - lib/bundler/spinel/load_bearing.rb
40
40
  - lib/bundler/spinel/localizer.rb
41
41
  - lib/bundler/spinel/platform.rb
42
42
  - lib/bundler/spinel/probe.rb
@@ -48,6 +48,7 @@ files:
48
48
  - lib/bundler/spinel/vendorer.rb
49
49
  - lib/bundler/spinel/verifier.rb
50
50
  - lib/bundler/spinel/version.rb
51
+ - lib/bundler/spinel/why.rb
51
52
  - plugins.rb
52
53
  homepage: https://github.com/OriPekelman/spinelgems
53
54
  licenses:
@@ -57,7 +58,6 @@ metadata:
57
58
  bug_tracker_uri: https://github.com/OriPekelman/spinelgems/issues
58
59
  changelog_uri: https://github.com/OriPekelman/spinelgems/blob/main/CHANGELOG.md
59
60
  rubygems_mfa_required: 'true'
60
- post_install_message:
61
61
  rdoc_options: []
62
62
  require_paths:
63
63
  - lib
@@ -72,8 +72,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
72
  - !ruby/object:Gem::Version
73
73
  version: '0'
74
74
  requirements: []
75
- rubygems_version: 3.4.20
76
- signing_key:
75
+ rubygems_version: 3.6.9
77
76
  specification_version: 4
78
77
  summary: Resolution-time gem-compatibility gating for the Spinel Ruby AOT compiler
79
78
  test_files: []