Skip to content

Commit ebfcb51

Browse files
OriPekelmanclaude
andcommitted
vendor: build-unit entries in spinel-ext.json (cmake|make) — spinelgems#14
A 'build' entry declares a constrained native build (tool cmake|make, dir, args, targets, artifacts) run inside the consumer's vendor tree, with {dir} link-flag expansion relative to it. Heavy-native gems (toy's vendored ggml) vendor self-contained + relocatable; the per-consumer post-vendor absolute- path rewrite hook and its ffi_manifest cflags canary retire. Override via SPINEL_EXT_<PLACEHOLDER> skips the build (prebuilt escape hatch). Smoke- tested: make + cmake paths, override path. Also: reprobe-corpus.sh now honors SPINEL_NO_FREEZE=1 (skip self-freeze when SPINEL_DIR is already a frozen copy) — the in-repo freeze it otherwise creates is the stray-nested-dir class that corrupted the 9c0a5f0 freeze. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5d8c95e commit ebfcb51

3 files changed

Lines changed: 140 additions & 5 deletions

File tree

bin/reprobe-corpus.sh

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,20 @@ elif command -v nproc >/dev/null 2>&1; then SHARDS="$(nproc)"
2929
else SHARDS=4; fi
3030

3131
# Freeze the Spinel checkout (rev-stable for the whole run), like survey-run.sh.
32+
# SPINEL_NO_FREEZE=1 skips the self-freeze — set it when SPINEL_DIR already IS
33+
# a frozen copy (e.g. /srv/data/scratch/spinelgems-rp/spinel-frozen-<rev>).
34+
# The in-repo freeze this would otherwise create is exactly the stray nested
35+
# dir that corrupted the 9c0a5f0 freeze; harness-run.sh honors the same flag.
3236
SP_SRC="${SPINEL_DIR:-$HOME/spinel}"
3337
SP_REV="$(git -C "$SP_SRC" rev-parse --short HEAD 2>/dev/null || echo unknown)"
34-
SP_FROZEN="$HERE/spinel-frozen-$SP_REV"
35-
if [ ! -d "$SP_FROZEN" ]; then
36-
echo "[reprobe] freezing $SP_SRC -> $SP_FROZEN"
37-
cp -al "$SP_SRC" "$SP_FROZEN" 2>/dev/null || cp -r "$SP_SRC" "$SP_FROZEN"
38+
if [ -z "${SPINEL_NO_FREEZE:-}" ]; then
39+
SP_FROZEN="$HERE/spinel-frozen-$SP_REV"
40+
if [ ! -d "$SP_FROZEN" ]; then
41+
echo "[reprobe] freezing $SP_SRC -> $SP_FROZEN"
42+
cp -al "$SP_SRC" "$SP_FROZEN" 2>/dev/null || cp -r "$SP_SRC" "$SP_FROZEN"
43+
fi
44+
export SPINEL_DIR="$SP_FROZEN"
3845
fi
39-
export SPINEL_DIR="$SP_FROZEN"
4046

4147
OUT="${2:-$HERE/survey-$SP_REV}"
4248
mkdir -p "$OUT"

docs/c-ext.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,41 @@ works fine without; the hook is small when we want it.)
218218
- **Trust is unchanged.** This is placement/build wiring, not gating — a
219219
C-ext gem still probes `risky` and earns trust only through the `verified`
220220
rung (a behaviour smoke through the harness).
221+
222+
## Build-units: heavy native deps (spinelgems#14)
223+
224+
Per-`.c` `source` entries fit small shims (tep's `sphttp.c`). They can't
225+
express toy's case: `tinynn/*.c` shims *plus a vendored ggml CMake build*
226+
producing three static archives. That gap forced toy's consumers into a
227+
per-consumer post-vendor hook that rewrote vendored `-L` flags into
228+
**absolute paths into toy's checkout** — non-relocatable, unpublishable.
229+
230+
A `build` entry declares the native build instead:
231+
232+
```json
233+
{ "name": "ggml",
234+
"build": {
235+
"tool": "cmake", // or "make" — only these two
236+
"dir": "vendor/ggml", // relative to gem root
237+
"args": ["-DBUILD_SHARED_LIBS=OFF"], // configure args (cmake) / make args
238+
"targets": ["ggml", "ggml-cpu"], // build targets (optional)
239+
"artifacts": ["build/src/libggml.a"] // verified to exist post-build
240+
},
241+
"placeholder": "@GGML_LINK@",
242+
"link": ["-L{dir}/build/src", "-L{dir}/build/src/ggml-cpu"] }
243+
```
244+
245+
At vendor time: the `dir` is **copied into the consumer's vendor tree**, the
246+
tool runs there, artifacts are verified, and `{dir}` in `link` expands to the
247+
*vendored* path (project-relative when `--into` is relative, the default) —
248+
self-contained and relocatable, the same end-state tep's small shims already
249+
had. A consumer override (`SPINEL_EXT_<PLACEHOLDER>` / `--ext`) supplies
250+
replacement flags and skips the build entirely (the prebuilt escape hatch).
251+
252+
Why this doesn't break "Gemfiles are Gemfiles": CRuby gems already run
253+
arbitrary native builds at install time — `extconf.rb`, rake-compiler, and
254+
most on-point **nokogiri building its vendored libxml2 via mini_portile2**,
255+
plus rubygems' native Cargo builder. A *declared* cmake/make unit is the
256+
Spinel analogue of `extensions:` in a gemspec — strictly narrower than the
257+
extconf precedent (no free-form shell, declared artifacts, auditable), and
258+
it's what lets a heavy-native gem publish to RubyGems as a normal gem.

lib/bundler/spinel/vendorer.rb

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,31 @@ def wire_extensions(src, dest, overrides, disable)
128128
next
129129
end
130130

131+
# Build-unit entry (spinelgems#14): a declared native build (cmake|make)
132+
# producing archives *inside the consumer's vendor tree*, with `link`
133+
# flags expanded relative to it ({dir} -> the vendored build dir). This
134+
# is the heavy-native analogue of `source` per-.c entries — nokogiri's
135+
# mini_portile2 precedent, Spinel-shaped. It replaces the per-consumer
136+
# post-vendor absolute-path rewrite hooks (toy's prep/post_vendor_toy.rb)
137+
# that made vendored trees non-relocatable and toy unpublishable.
138+
# A consumer override (SPINEL_EXT_<PLACEHOLDER> / --ext) supplies the
139+
# full replacement flags and skips the build (prebuilt escape hatch).
140+
if e["build"]
141+
if placeholder && (ov = overrides[placeholder] || ENV[ext_env_key(placeholder)])
142+
substitute_placeholder(dest, placeholder, ov.to_s)
143+
wired += 1
144+
next
145+
end
146+
ven_dir = build_unit(src, dest, e) or next # build failed (warned)
147+
if placeholder
148+
parts = Array(e["link"]).map { |t| t.gsub("{dir}", ven_dir) }
149+
parts.concat(Array(e["libs"]))
150+
substitute_placeholder(dest, placeholder, parts.join(" ").strip)
151+
end
152+
wired += 1
153+
next
154+
end
155+
131156
# Compile / place the .o (or take a prebuilt override path). Both forms
132157
# need this; post-#1011 const-fold form skips the substitution below.
133158
obj = nil
@@ -214,6 +239,72 @@ def compile_ext(src, dest, entry, extra_cflags = [])
214239
nil
215240
end
216241

242+
# Build-unit (spinelgems#14): copy the gem's declared build dir into the
243+
# vendor tree, run the declared tool there, verify the declared artifacts.
244+
# Returns the vendored dir path (project-relative when `into` was given
245+
# relative — the usual case — so substituted -L flags stay relocatable
246+
# with the consumer project) or nil on failure (warned, entry skipped).
247+
#
248+
# The tool surface is deliberately constrained to cmake|make with declared
249+
# args/targets/artifacts — no free-form shell. extconf.rb is precedent for
250+
# arbitrary install-time code in gems, but there's no need to copy that
251+
# mistake into spinel-ext.json: a declarative unit stays auditable and the
252+
# detector-inferable, consumer-side philosophy survives.
253+
def build_unit(src, dest, entry)
254+
b = entry["build"]
255+
dir_rel = b["dir"].to_s
256+
src_dir = File.join(src, dir_rel)
257+
unless File.directory?(src_dir)
258+
warn "[vendor] build dir not found for #{entry['name'] || entry['placeholder']}: #{dir_rel}"
259+
return nil
260+
end
261+
262+
ven_dir = File.join(dest, dir_rel)
263+
FileUtils.mkdir_p(File.dirname(ven_dir))
264+
FileUtils.rm_rf(ven_dir)
265+
FileUtils.cp_r(src_dir, ven_dir)
266+
267+
jobs = begin
268+
require "etc"
269+
Etc.nprocessors.to_s
270+
rescue StandardError
271+
"4"
272+
end
273+
cmds =
274+
case b["tool"].to_s
275+
when "cmake"
276+
build_dir = File.join(ven_dir, "build")
277+
cfg = ["cmake", "-S", ven_dir, "-B", build_dir, *Array(b["args"]).map(&:to_s)]
278+
bld = ["cmake", "--build", build_dir, "-j", jobs]
279+
targets = Array(b["targets"]).map(&:to_s)
280+
bld.push("--target", *targets) unless targets.empty?
281+
[cfg, bld]
282+
when "make"
283+
[["make", "-C", ven_dir, "-j", jobs,
284+
*Array(b["args"]).map(&:to_s), *Array(b["targets"]).map(&:to_s)]]
285+
else
286+
warn "[vendor] unknown build tool #{b['tool'].inspect} for #{entry['name']} (cmake|make)"
287+
return nil
288+
end
289+
290+
cmds.each do |cmd|
291+
out, st = Open3.capture2e(*cmd)
292+
unless st.success?
293+
warn "[vendor] build failed (#{entry['name']}): #{cmd.take(2).join(' ')} ... : " \
294+
"#{out.lines.last(3).join.strip}"
295+
return nil
296+
end
297+
end
298+
299+
missing = Array(b["artifacts"]).reject { |a| File.exist?(File.join(ven_dir, a.to_s)) }
300+
unless missing.empty?
301+
warn "[vendor] build for #{entry['name']} succeeded but artifacts missing: #{missing.join(', ')}"
302+
return nil
303+
end
304+
305+
ven_dir
306+
end
307+
217308
def substitute_placeholder(dest, placeholder, repl)
218309
Dir[File.join(dest, "**", "*.rb")].each do |f|
219310
body = File.read(f)

0 commit comments

Comments
 (0)