bundler-spinel 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f60b276e969f601d5931d3df6b6e7177820f58609d18e1082e49784e4acf7ce3
4
- data.tar.gz: 9a5b89828d86ab07cacbb3d85a63ff7167d0cecc83e96138ca4de28e19c51083
3
+ metadata.gz: 8518bfe1ec1303fb7116e33738abb8246bb3c7072afca1b70057523f824b4a2c
4
+ data.tar.gz: 5d945e9ca4fa4409f5ff325a6eae9d917469e5c0e7ceb2ef69c8c356551c0fda
5
5
  SHA512:
6
- metadata.gz: 60290945a8663c019299e28553af1c5bbd6766f8606a1b8675142653b1cd2692eae7e3eb9d7d243fbbfa05765c58f44eee74fb9ebb1852606bf2ecff9ce7ec9b
7
- data.tar.gz: fb8b90c9cec7bda764f34625fd3643c27facb676c9a951551fd2a53a1c365e782acd0bd7d318a21c50062ea37c9f41c3105761ab01974d87fa51bfc329a90102
6
+ metadata.gz: ff071af5cde1af69f32f92b509092ec6d535173dfbbdbe84c02940b5118abb1f7cafacc610cc26a4c3e20263426e4a7422560ba166c766ff296d391be1758e34
7
+ data.tar.gz: e704f7c8a825a0cdb278c604a86bb38fd9363ac0fe8fe7406c88c52e9b77e1a76cd2675036d503b3e80fe488f27f3ff1820c7803428989f7ac1930bdaff9a412
data/CHANGELOG.md CHANGED
@@ -4,6 +4,62 @@ 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.4.0] — 2026-06-15
8
+
9
+ ### Fixed
10
+ - **Probe: a require-only compile failure is the load-path limit, not
11
+ `analyze-failed` (spinel master b60fbd7 absorption).** Since matz/spinel's
12
+ #1383 fix, an unresolvable plain `require "gem/sub"` makes `spinel -c` exit
13
+ non-zero instead of warning and continuing. Because that idiom is
14
+ near-universal, the b60fbd7 corpus reprobe spuriously turned thousands of
15
+ previously-`clean` gems into `rejected:analyze-failed`. The probe now detects
16
+ a compile that failed *solely* on an unresolvable `require` (the
17
+ `CallNode \`require\`` form, with no real codegen error and no other
18
+ unsupported call) and classifies it `risky [load-path:require]` — the
19
+ documented no-load-path limitation a real Spinel project resolves by
20
+ vendoring. Real codegen errors still reject. +`test/probe_classify_test.rb`.
21
+ - **Engine rev detection for worktrees + frozen copies.** `Engine#rev` fell
22
+ back to an opaque `bin:<hash>` (a) in a git *worktree*, where `.git` is a
23
+ file not a directory, and (b) for a frozen/detached engine copy with no
24
+ `.git`. Both now resolve to `git:<short-sha>`: worktree `.git` files are
25
+ honored (`File.exist?`, not `File.directory?`), and a `.spinel_rev` stamp
26
+ (a bare HEAD sha written at freeze time) is read first. Keeps the ledger
27
+ key — and the history/diff tooling that joins on it — consistent across the
28
+ build layouts a reprobe actually uses. `bin/reprobe-corpus.sh`'s frozen
29
+ engines now carry the stamp.
30
+ - **cmake build-units on macOS (spinelgems#21).** The Vendorer now sets the
31
+ SDK libc++ include path (`CPLUS_INCLUDE_PATH` via `xcrun --show-sdk-path`)
32
+ for cmake build-unit invocations on Darwin — without it every C++ unit died
33
+ with `fatal error: 'array' file not found` (toy#27's Mac cold-start LIB
34
+ leg; fix verified on an M2). Caller-set `CPLUS_INCLUDE_PATH` is preserved
35
+ (SDK path appends); missing/failing `xcrun` degrades to the previous
36
+ behavior.
37
+
38
+ ### Added
39
+ - **Opt-in build-units + variant build dirs (spinelgems#20).** An `optional`
40
+ entry may declare `"default": "disabled"`: a plain `vendor` skips its build
41
+ entirely (placeholder ← `disabled_cflags`) until the consumer enables it via
42
+ `--with-ext NAME` / `SPINEL_EXT_ENABLE` — what a CUDA unit needs to not
43
+ attempt nvcc configures on a CUDA-less box. Explicit disable beats enable;
44
+ unknown enable names warn loud. cmake units take `"build_dir"` (default
45
+ `"build"`, relative-only) so two entries can build one shared source dir
46
+ into variant trees (`build/` vs `build-cuda/`), and source dirs are copied
47
+ once per vendor run — a second entry sharing `dir` no longer wipes the first
48
+ unit's just-built artifacts. Unblocks toy merging its staged
49
+ `spinel-ext-gpu.json` into `spinel-ext.json` (toy#45 Phase 3 → publish).
50
+ - **A gem's `sig/*.rbs` acts as the type root (spinelgems#13).** Spinel infers
51
+ param types from call sites (whole-program, closed-world), so an *uncalled*
52
+ public method widens to `int`/`poly` — the failure mode hand-written seed
53
+ blocks exist to patch. `--rbs` was verified to re-pin uncalled methods' param,
54
+ return, and ivar types, so the tooling now treats a shipped `sig/` tree as
55
+ authoritative: `spinel-compat verify` auto-passes `--rbs <gem>/sig` when the
56
+ gem ships signatures (`--rbs DIR` to override, `--no-rbs` to opt out), and
57
+ records an `rbs:sig` provenance tag in the verdict reasons. `vendor` copies
58
+ each gem's `sig/` alongside `lib/`, aggregates all of them under
59
+ `<into>/sig/<gem>/`, and advertises the single `--rbs <into>/sig` root in
60
+ `deps.rb` and the CLI output. A spinel-native gem (SpinelKit, the Tep
61
+ batteries) ships one standard Ruby artifact — no seed soup, no manifest keys.
62
+
7
63
  ## [0.3.0] — 2026-06-08
8
64
 
9
65
  ### Added
@@ -146,7 +146,11 @@ module Bundler
146
146
  smoke = (j = argv.index("--smoke")) ? argv.delete_at(j + 1).tap { argv.delete_at(j) } : nil
147
147
  full = !!argv.delete("--full")
148
148
  tests = !!argv.delete("--tests")
149
- name = argv.shift or raise Error, "usage: spinel-compat verify NAME [VERSION] [--dir PATH] [--smoke FILE | --tests] [--full]"
149
+ # --rbs DIR / --no-rbs: explicit type root / opt out. Default :auto
150
+ # use the gem's own sig/*.rbs when shipped (spinelgems#13).
151
+ rbs = (k = argv.index("--rbs")) ? argv.delete_at(k + 1).tap { argv.delete_at(k) } : :auto
152
+ rbs = false if argv.delete("--no-rbs")
153
+ name = argv.shift or raise Error, "usage: spinel-compat verify NAME [VERSION] [--dir PATH] [--smoke FILE | --tests] [--full] [--rbs DIR | --no-rbs]"
150
154
  engine = Engine.new
151
155
  if dir
152
156
  version = argv.shift || "path"
@@ -164,7 +168,7 @@ module Bundler
164
168
  smoke = File.join(Dir.tmpdir, "__spinel_tests_#{name}.rb")
165
169
  File.write(smoke, runner)
166
170
  end
167
- v = Verifier.new(engine, Ledger.new).verify(name, version, gem_dir, smoke: smoke && File.expand_path(smoke), full: full)
171
+ v = Verifier.new(engine, Ledger.new).verify(name, version, gem_dir, smoke: smoke && File.expand_path(smoke), full: full, rbs: rbs)
168
172
  print_verdict(v)
169
173
  File.delete(smoke) if tests && smoke && File.exist?(smoke)
170
174
  (v.verified? || v.loaded?) ? 0 : 1
@@ -186,13 +190,26 @@ module Bundler
186
190
  ext_disable << argv.delete_at(d + 1).to_s
187
191
  argv.delete_at(d)
188
192
  end
193
+ # --with-ext NAME : opt IN to a default-disabled entry, e.g. a CUDA
194
+ # build-unit (repeatable; also SPINEL_EXT_ENABLE). spinelgems#20.
195
+ ext_enable = []
196
+ while (w = argv.index("--with-ext"))
197
+ ext_enable << argv.delete_at(w + 1).to_s
198
+ argv.delete_at(w)
199
+ end
189
200
  lock = argv.shift || "Gemfile.lock"
190
201
  raise Error, "no #{lock}; run `bundle lock` first" unless File.exist?(lock)
191
202
 
192
- res = Vendorer.new.vendor(lock, into: into, ext_overrides: ext_overrides, ext_disable: ext_disable)
203
+ res = Vendorer.new.vendor(lock, into: into, ext_overrides: ext_overrides,
204
+ ext_disable: ext_disable, ext_enable: ext_enable)
193
205
  ext = res[:extensions].to_i
194
206
  @out.puts "vendored #{res[:count]} gem(s)#{ext.positive? ? " (+#{ext} C ext)" : ''} -> #{res[:into]}"
195
207
  @out.puts " require_relative \"#{res[:into]}/deps\" from your Spinel entrypoint"
208
+ sigs = res[:sig_gems] || []
209
+ unless sigs.empty?
210
+ @out.puts " #{sigs.size} gem(s) ship sig/*.rbs type roots -> #{res[:into]}/sig"
211
+ @out.puts " compile with: spinel ... --rbs #{res[:into]}/sig (spinelgems#13)"
212
+ end
196
213
  0
197
214
  end
198
215
 
@@ -469,7 +486,9 @@ module Bundler
469
486
  spinel-compat probe NAME [VERSION] probe one gem, record a verdict
470
487
  spinel-compat why NAME [--probe] legible "why doesn't this work (yet)?" report
471
488
  spinel-compat verify NAME [--smoke F] differential CRuby-vs-Spinel run -> verified
472
- spinel-compat vendor [LOCK] [--into D] place deps where Spinel finds them + deps.rb
489
+ [--rbs DIR | --no-rbs] type root: gem's sig/*.rbs (auto when shipped)
490
+ spinel-compat vendor [LOCK] [--into D] place deps where Spinel finds them + deps.rb (+ sig/ roots)
491
+ [--with-ext NAME | --no-ext NAME] opt in/out of optional C-ext units
473
492
  spinel-compat check [LOCK] [--strict] gate a Gemfile.lock (exit 1 if rejected)
474
493
  spinel-compat survey GEM... | --list F wholesale review -> reason histogram
475
494
  spinel-compat serve --store DIR curated source (only vetted gems)
@@ -76,7 +76,17 @@ module Bundler
76
76
  private
77
77
 
78
78
  def compute_rev
79
- if File.directory?(File.join(@dir, ".git"))
79
+ # A frozen/detached engine copy (bin/reprobe-corpus.sh's
80
+ # spinel-frozen-<rev>, or a build whose .git was stripped) can carry a
81
+ # `.spinel_rev` stamp — a bare HEAD sha written at freeze time — so it
82
+ # still keys to its real git rev instead of an opaque binary hash.
83
+ stamp = File.join(@dir, ".spinel_rev")
84
+ if File.file?(stamp) && !(s = File.read(stamp).strip[0, 12].to_s).empty?
85
+ return "git:#{s}"
86
+ end
87
+ # `.git` is a *file* in a git worktree (a gitdir pointer), not a dir —
88
+ # File.exist? covers both so worktree checkouts report git:, not bin:.
89
+ if File.exist?(File.join(@dir, ".git"))
80
90
  sha = capture("git", "-C", @dir, "rev-parse", "--short", "HEAD")
81
91
  if sha && !sha.empty?
82
92
  dirty = capture("git", "-C", @dir, "status", "--porcelain")
@@ -83,6 +83,32 @@ module Bundler
83
83
  "to Ruby source lines via <code>#line</code>, on by default — the " \
84
84
  "<a href=\"https://github.com/matz/spinel/issues/1338\">#1338</a> RFC direction. The behaviour-verified " \
85
85
  "tier reached 144 mechanical ★ this run." },
86
+ { rev: "cb23cc6", date: "2026-06-09", commit: "~23 commits — 12 harness-filed issues closed in one wave, incl. Mutex/Monitor#synchronize + Thread.new now running and carrying their block value (#1360), class-instance-vars in class methods (#1352), is_a?(IncludedModule)/ancestors (#1350), \\h/\\H regex (#1349), reopened-builtin optional defaults (#1348), bitwise/shift operator mangling (#1358/#1368), the captured-&block value/type family",
87
+ file: "survey-cb23cc6/compat.jsonl",
88
+ note: "<strong>Mutex/Thread come in from the cold.</strong> With matz/spinel#1360 landing " \
89
+ "(<code>Mutex#synchronize</code>/<code>Thread.new</code> now run single-threaded and carry their " \
90
+ "block's value), the static pre-filter no longer hard-rejects them — they're flagged " \
91
+ "<code>risky</code> (correct for defensive use, degenerate only for true concurrency). Re-probing " \
92
+ "the 8,833 gems that had been rejected on sight for <code>Mutex.new</code>/<code>Thread.new</code>, " \
93
+ "<strong>3,493 (39.5%) moved out of <code>rejected</code></strong> — they compile now; the static " \
94
+ "wall had been hiding it. The four spine gems (<code>bundler</code>/<code>rake</code>/" \
95
+ "<code>minitest</code>/<code>thor</code>) now reject for their <em>real</em> reason — the deep " \
96
+ "metaprogramming surface (<code>send</code>/<code>method_missing</code>/<code>cattr_accessor</code>) — " \
97
+ "not a misleading <code>hard:Mutex.new</code>. The differential re-audit also caught one regression in " \
98
+ "the wave: returning <code>self</code> from a reopened-builtin method " \
99
+ "(<a href=\"https://github.com/matz/spinel/issues/1386\">#1386</a>, <code>to-bool</code>), filed same day." },
100
+ { rev: "b60fbd7", date: "2026-06-15", commit: "636 commits — the largest wave yet. A type-inference + codegen rewrite (typed-array/poly ~222, inference/cast/box ~168, string ~124, block/yield ~101, module/singleton ~94) closing many harness-filed issues at once: reopened-builtin self (#1386), hash-block String type (#1382/#1394), circular require_relative (#1373), computed require (#1383), class-method yield (#1387), Scheduled-server boot + SIGTERM (#1369/#1384). New surfaces: value-type objects, --rbs/--emit-rbs/--emit-symbol-map.",
101
+ file: "survey-b60fbd7/compat.jsonl",
102
+ note: "<strong>The largest movement in the catalog's history.</strong> 636 upstream commits — a type-inference " \
103
+ "rewrite that resolved the entire <code>unresolved:&lt;method&gt;</code> reject family (~60k records → ~0): " \
104
+ "<strong>~49,000 gems moved out of <code>rejected</code></strong> (−46%), +22k to <code>clean</code>. The " \
105
+ "wave also changed unresolvable <code>require</code> to hard-fail the compile (post-#1383); because " \
106
+ "<code>require \"gem/version\"</code> is near-universal that spuriously rejected thousands, so the probe " \
107
+ "now classifies a require-only failure as the no-load-path limit (<code>risky [load-path:require]</code>, " \
108
+ "9,716 gems corrected). Cost side, caught by re-verifying every ★ at this rev: <strong>~34 of 175 " \
109
+ "behaviour-verified gems regressed</strong> (12 miscompiles incl. <code>after_commit_action</code>, 22 " \
110
+ "codegen incl. bare-<code>super</code>/<code>ForwardingSuperNode</code>) — the trust tier dropped 175→136. " \
111
+ "Fresh-verify overrode stickiness, so the ★ count is honest, not carried." },
86
112
  ].freeze
87
113
 
88
114
  ORDER = %w[clean risky rejected].freeze
@@ -65,17 +65,19 @@ module Bundler
65
65
  file.empty? ? "line #{v['line']}" : "#{file}:#{v['line']}"
66
66
  end
67
67
 
68
- # Resolve bisect.sh. It lives in spinel-dev (separate from the compiler
69
- # engine), so there's no fixed relation to the engine dir — probe a few
70
- # conventional spots, newest-intent first: an explicit override, a sibling
71
- # spinel-dev checkout next to the engine, the conventional ~/sites layout.
68
+ # Resolve bisect.sh. It lives in the spinel-dev tooling repo (separate from
69
+ # the compiler engine), so there's no fixed relation to the engine dir —
70
+ # probe a few conventional spots, newest-intent first: an explicit override,
71
+ # a sibling checkout next to the engine, the conventional ~/sites layout.
72
+ # The local checkout may be named spinel-tools (gx10) or spinel-dev (the
73
+ # GitHub repo name), so probe both.
72
74
  def bisect_script
73
75
  rel = "tools/value-bisect/bisect.sh"
74
- candidates = [
75
- ENV["SPINEL_BISECT"],
76
- File.expand_path(File.join(@engine.dir, "..", "spinel-dev", rel)),
77
- File.expand_path("~/sites/spinel-dev/#{rel}")
78
- ]
76
+ candidates = [ENV["SPINEL_BISECT"]]
77
+ %w[spinel-tools spinel-dev].each do |repo|
78
+ candidates << File.expand_path(File.join(@engine.dir, "..", repo, rel))
79
+ candidates << File.expand_path("~/sites/#{repo}/#{rel}")
80
+ end
79
81
  candidates.find { |c| c && File.exist?(c) }
80
82
  end
81
83
  end
@@ -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
@@ -29,15 +29,24 @@ 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
47
+ sig_root = File.join(into, "sig")
48
+ FileUtils.rm_rf(sig_root)
49
+ sig_gems = []
41
50
  # Topological order (dependencies before dependents), not the lockfile's
42
51
  # alphabetical `specs` (spinelgems#19): Spinel has no load path, so
43
52
  # deps.rb is a *flattened single load* — each gem's entrypoint
@@ -50,15 +59,20 @@ module Bundler
50
59
  src = resolve_source(spec, lock_dir)
51
60
  dest = File.join(into, name)
52
61
  place(src, dest)
53
- exts += wire_extensions(src, dest, ext_overrides, disable)
62
+ exts += wire_extensions(src, dest, ext_overrides, disable, enable)
63
+ sig_gems << name if collect_sig(dest, sig_root, name)
54
64
  if (target = require_target(name, dest))
55
65
  manifest << { require: target, libdir: "#{File.basename(dest)}/lib" }
56
66
  end
57
67
  note_compat(name, version) if warn_incompatible
58
68
  end
59
69
 
60
- write_manifest(into, manifest)
61
- { 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 }
62
76
  end
63
77
 
64
78
  # Order specs so every gem's runtime dependencies come before it — a DFS
@@ -108,12 +122,29 @@ module Bundler
108
122
  def place(src, dest)
109
123
  FileUtils.rm_rf(dest)
110
124
  FileUtils.mkdir_p(dest)
111
- %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|
112
129
  s = File.join(src, sub)
113
130
  FileUtils.cp_r(s, dest) if File.directory?(s)
114
131
  end
115
132
  end
116
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
+
117
148
  # Build + wire any C extensions the gem declares in spinel-ext.json
118
149
  # (OriPekelman/spinelgems#2; see docs/c-ext.md). Each entry substitutes its
119
150
  # @PLACEHOLDER@ in the placed Ruby — so the gem's `ffi_cflags "@PLACEHOLDER@"`
@@ -125,8 +156,13 @@ module Bundler
125
156
  # - any static `libs`.
126
157
  # An `optional` entry the consumer opted out of (`name` in `disable` /
127
158
  # SPINEL_EXT_DISABLE) substitutes its `disabled_cflags` instead (category C).
128
- # Returns the count wired. A no-op for gems without the manifest.
129
- 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 = [])
130
166
  manifest = File.join(src, "spinel-ext.json")
131
167
  return 0 unless File.exist?(manifest)
132
168
 
@@ -143,7 +179,7 @@ module Bundler
143
179
  sib_cflags = Hash.new { |h, k| h[k] = [] }
144
180
  entries.each do |e|
145
181
  next if e["source"] || !e["name"] # only CFLAGS-only siblings
146
- next if e["optional"] && disable.include?(e["name"].to_s)
182
+ next if ext_disabled?(e, disable, enable)
147
183
  (cf = pkg_config_cflags(e)) && sib_cflags[e["name"].to_s].concat(cf)
148
184
  sib_cflags[e["name"].to_s].concat(Array(e["cflags"])) if e["cflags"]
149
185
  end
@@ -156,9 +192,11 @@ module Bundler
156
192
  entries.each do |e|
157
193
  placeholder = e["placeholder"]
158
194
  name = e["name"]
195
+ (@ext_names_seen ||= []) << name.to_s if name && e["optional"]
159
196
 
160
- # Opt-out (only meaningful with a placeholder to write disabled_cflags into).
161
- 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)
162
200
  substitute_placeholder(dest, placeholder, e["disabled_cflags"].to_s) if placeholder
163
201
  wired += 1
164
202
  next
@@ -225,6 +263,17 @@ module Bundler
225
263
  0
226
264
  end
227
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
+
228
277
  # System libs/cflags for an entry, resolved at the consumer's environment:
229
278
  # `pkg-config --cflags --libs <name>`, else `pkg_config_fallback`, else nil
230
279
  # (leave the placeholder so the build fails loud — we never silently drop a
@@ -300,18 +349,28 @@ module Bundler
300
349
  end
301
350
 
302
351
  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))
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
315
374
  end
316
375
 
317
376
  # Declared patches (toy#45: pristine vendored ggml + vendor-patches/*.patch),
@@ -360,7 +419,15 @@ module Bundler
360
419
  cmds =
361
420
  case b["tool"].to_s
362
421
  when "cmake"
363
- build_dir = File.join(ven_dir, "build")
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)
364
431
  cfg = ["cmake", "-S", ven_dir, "-B", build_dir, *Array(b["args"]).map(&:to_s)]
365
432
  bld = ["cmake", "--build", build_dir, "-j", jobs]
366
433
  targets = Array(b["targets"]).map(&:to_s)
@@ -374,8 +441,9 @@ module Bundler
374
441
  return nil
375
442
  end
376
443
 
444
+ env = build_env(b["tool"].to_s)
377
445
  cmds.each do |cmd|
378
- out, st = Open3.capture2e(*cmd)
446
+ out, st = Open3.capture2e(env, *cmd)
379
447
  unless st.success?
380
448
  warn "[vendor] build failed (#{entry['name']}): #{cmd.take(2).join(' ')} ... : " \
381
449
  "#{out.lines.last(3).join.strip}"
@@ -397,6 +465,26 @@ module Bundler
397
465
  ven_dir.start_with?(pwd) ? ven_dir.delete_prefix(pwd) : ven_dir
398
466
  end
399
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
+
400
488
  # A placeholder that substitutes ZERO files is drift (toy#45: a gem whose
401
489
  # ffi_cflags line moved out from under its manifest's literal-string
402
490
  # placeholder) — warn loud. This replaces per-gem canary hacks like toy's
@@ -443,11 +531,16 @@ module Bundler
443
531
  "— may not compile (run `spinel-compat check`)"
444
532
  end
445
533
 
446
- def write_manifest(into, entries)
534
+ def write_manifest(into, entries, sig_gems = [])
447
535
  es = entries.compact
448
536
  body = +"# Generated by bundler-spinel. require_relative this from a\n" \
449
537
  "# Spinel program to pull in vendored dependencies (topo order:\n" \
450
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
451
544
  # Put each vendored gem's lib root on $LOAD_PATH so a dependent's plain
452
545
  # `require "<depgem>"` resolves under CRuby too (the differential verify
453
546
  # and plain-Ruby dev runs). Spinel has no load path: it ignores both the
@@ -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)
@@ -1,9 +1,12 @@
1
1
  module Bundler
2
2
  module Spinel
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"
3
+ # 0.4.0: a gem's shipped `sig/*.rbs` acts as the Spinel type root —
4
+ # `verify` auto-`--rbs`, `vendor` aggregates one root, retiring seed soup
5
+ # (#13); opt-in (`"default":"disabled"`) build-units + variant `build_dir`
6
+ # over a shared source for optional CUDA/Metal backends (#20); macOS SDK
7
+ # libc++ path for cmake build-units (#21); and probe/engine robustness for
8
+ # the b60fbd7 corpus reprobe (require-only → load-path limit; git rev for
9
+ # worktrees/frozen copies).
10
+ VERSION = "0.4.0"
8
11
  end
9
12
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundler-spinel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ori Pekelman