Skip to content

Commit cf9dc2a

Browse files
OriPekelmanclaude
andcommitted
vendor: emit spinel-flags + committed-sibling drift guard (zero hand-wiring)
Closing the remaining cross-repo communication in the vendoring convention (audit of #3/#8/#13/#14/#19/#20/#21). The machinery is already zero-hand-wiring when the producer is a clean published gem (toy proves it); the residual talking concentrated in two gaps: 1. vendor emits vendor/spinel/spinel-flags — the compile flags to use what vendor produced (today --rbs <into>/sig for the #13 type roots). The scaffold's bin/build sources it, so the consumer never hand-passes --rbs or hand-symlinks the sig root. Empty when no sig roots (cat-safe). 2. drift guard: vendor warns when a gem ships a hand-copied sibling — the lib/<X>/ + sig/<X>/ double-copy fingerprint for an undeclared <X> (tep's stale lib/spinel_kit/ + sig/spinel_kit/). Tells the producer to declare gem "<X>" so vendor manages it. Surfaces the exact drift the convention kills, at consume time. Confirmed firing on the real tep checkout. +tests in test/rbs_sig_root_test.rb (flags emission project-relative + cat-safe; drift guard: undeclared fires, declared silent, own-namespace + lib-only safe). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 085a8da commit cf9dc2a

5 files changed

Lines changed: 143 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,26 @@ All notable changes to `bundler-spinel` are documented here. The format
44
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
55
project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Added
10+
- **Vendor emits `vendor/spinel/spinel-flags`** — the engine flags a compile
11+
must pass to use what `vendor` produced (today the aggregated `--rbs
12+
<into>/sig` type root, #13), so a build step auto-applies them
13+
(`spinel app.rb $(cat vendor/spinel/spinel-flags) -o app`) instead of the
14+
consumer hand-wiring `--rbs`/a sig-symlink. Always written (empty when no
15+
sig roots) so the `$(cat …)` idiom never errors; the scaffold's `bin/build`
16+
now sources it. Closes the last per-consumer hand-step in the vendoring
17+
convention's happy path.
18+
- **Drift guard: committed-sibling-copy detection.** `vendor` now warns when a
19+
gem ships a *hand-copied* sibling gem inside its own tree — the fingerprint
20+
is BOTH `lib/<X>/` and `sig/<X>/` for an `<X>` that's neither the gem's own
21+
namespace nor a declared dependency (e.g. `tep` carrying `lib/spinel_kit/` +
22+
`sig/spinel_kit/` instead of depending on the gem; the copies drift). The
23+
message tells the producer to declare `gem "<X>"` so `vendor` manages it.
24+
This surfaces, at consume time, the cross-repo drift the convention exists
25+
to eliminate (spinelgems#19). +tests.
26+
727
## [0.4.0] — 2026-06-15
828

929
### Fixed

lib/bundler/spinel/cli.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,9 @@ def cmd_vendor(argv)
208208
sigs = res[:sig_gems] || []
209209
unless sigs.empty?
210210
@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)"
211+
end
212+
if (ff = res[:flags_file])
213+
@out.puts " compile flags -> #{ff} (e.g. `spinel app.rb $(cat #{ff}) -o app`)"
212214
end
213215
0
214216
end

lib/bundler/spinel/engine_installer.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,11 @@ def build_sh
237237
spinel-compat install-engine # fetch+build the pinned engine (cached)
238238
export SPINEL="${SPINEL:-$HOME/.cache/spinel/current/spinel}" # tell tep where the engine is
239239
spinel-compat vendor # place deps where Spinel follows them
240-
tep build app.rb -o app # compile -> ./app
240+
# vendor writes vendor/spinel/spinel-flags (e.g. `--rbs vendor/spinel/sig`
241+
# for the aggregated sig type roots, spinelgems#13) so the compile uses
242+
# what vendor produced with no hand-wiring.
243+
SPINEL_FLAGS="$(cat vendor/spinel/spinel-flags 2>/dev/null || true)"
244+
tep build app.rb $SPINEL_FLAGS -o app # compile -> ./app
241245
echo "built ./app — run it with: ./app -p 4567"
242246
SH
243247
end

lib/bundler/spinel/vendorer.rb

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,38 @@ def vendor(lockfile = "Gemfile.lock", into: "vendor/spinel", warn_incompatible:
6565
manifest << { require: target, libdir: "#{File.basename(dest)}/lib" }
6666
end
6767
note_compat(name, version) if warn_incompatible
68+
warn_committed_siblings(src, spec) if warn_incompatible
6869
end
6970

7071
unless (unknown = enable - @ext_names_seen).empty?
7172
warn "[vendor] --with-ext/SPINEL_EXT_ENABLE names matched no optional " \
7273
"entry in any vendored manifest: #{unknown.join(', ')}"
7374
end
7475
write_manifest(into, manifest, sig_gems)
75-
{ into: into, count: manifest.size, extensions: exts, sig_gems: sig_gems }
76+
flags = write_compile_flags(into, sig_gems)
77+
{ into: into, count: manifest.size, extensions: exts, sig_gems: sig_gems, flags_file: flags }
78+
end
79+
80+
# Emit `<into>/spinel-flags` — the engine flags a compile must pass to use
81+
# what vendor produced, so the build step auto-applies them instead of the
82+
# consumer hand-wiring `--rbs` (the per-project sig-symlink/Makefile hack).
83+
# Today that's the aggregated sig type root (#13); the file is the seam for
84+
# future global flags. A build script does:
85+
# spinel app.rb $(cat vendor/spinel/spinel-flags) -o app
86+
# Project-relative when `into` is under cwd (the normal case) so the flag
87+
# survives a project move. Always written (empty when no sig roots) so the
88+
# `$(cat ...)` idiom never errors on a missing file.
89+
def write_compile_flags(into, sig_gems)
90+
path = File.join(into, "spinel-flags")
91+
parts = []
92+
unless sig_gems.empty?
93+
sig_root = File.join(into, "sig")
94+
pwd = Dir.pwd + File::SEPARATOR
95+
rel = sig_root.start_with?(pwd) ? sig_root.delete_prefix(pwd) : sig_root
96+
parts << "--rbs" << rel
97+
end
98+
File.write(path, parts.empty? ? "" : parts.join(" ") + "\n")
99+
path
76100
end
77101

78102
# Order specs so every gem's runtime dependencies come before it — a DFS
@@ -531,6 +555,44 @@ def note_compat(name, version)
531555
"— may not compile (run `spinel-compat check`)"
532556
end
533557

558+
# Drift guard: a gem that ships a *hand-copied* sibling gem inside its own
559+
# tree — the fingerprint is BOTH `lib/<X>/` and `sig/<X>/` for an `<X>`
560+
# that is neither the gem's own namespace nor a declared runtime dependency
561+
# (tep carries `lib/spinel_kit/`+`sig/spinel_kit/` instead of depending on
562+
# the gem; the copies have already gone stale). Copying both lib + sig is
563+
# unambiguously vendoring, not a coincidental sub-namespace. Warn so the
564+
# producer declares `gem "<X>"` and lets vendor manage it (#19 retired the
565+
# interim that forced these copies) — this is the cross-repo-drift the
566+
# convention exists to kill, surfaced at consume time. Returns warnings.
567+
def committed_sibling_warnings(src, spec)
568+
libdir = File.join(src, "lib")
569+
sigdir = File.join(src, "sig")
570+
return [] unless File.directory?(libdir) && File.directory?(sigdir)
571+
572+
own = own_namespaces(spec.name)
573+
declared = spec.dependencies.map { |d| d.respond_to?(:name) ? d.name : d.to_s }
574+
Dir.children(libdir).filter_map do |child|
575+
x = child[%r{\A(.+?)(?:\.rb)?\z}, 1]
576+
next if x.nil? || own.include?(x) || declared.include?(x)
577+
next unless File.directory?(File.join(libdir, x)) # a lib/<X>/ tree
578+
next unless File.directory?(File.join(sigdir, x)) # AND sig/<X>/ — the copy fingerprint
579+
"[vendor] #{spec.name} ships a hand-copied `#{x}` (lib/#{x}/ + sig/#{x}/) " \
580+
"but does not declare it as a dependency — it will drift. Declare " \
581+
"`gem \"#{x}\"` so `spinel-compat vendor` manages it (spinelgems#19)."
582+
end
583+
end
584+
585+
def warn_committed_siblings(src, spec)
586+
committed_sibling_warnings(src, spec).each { |w| warn w }
587+
end
588+
589+
# Names that legitimately belong to this gem's own tree: its gem name and
590+
# the dashed→slashed first segment (`a-b` → `a`), so a gem's own nested
591+
# namespace never trips the sibling-copy guard.
592+
def own_namespaces(name)
593+
[name, name.tr("-", "/").split("/").first].compact.uniq
594+
end
595+
534596
def write_manifest(into, entries, sig_gems = [])
535597
es = entries.compact
536598
body = +"# Generated by bundler-spinel. require_relative this from a\n" \

test/rbs_sig_root_test.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,58 @@ def ensure! = true
9898
vend.send(:write_manifest, into, [{ require: "g/lib/g", libdir: "g/lib" }], [])
9999
check(!File.read(File.join(into, "deps.rb")).include?("--rbs"),
100100
"no sig gems -> no --rbs note")
101+
102+
# spinel-flags: vendor emits the compile flags so the build auto-applies --rbs
103+
# (no per-consumer hand-wiring). Project-relative, empty when no sig roots.
104+
Dir.chdir(dir) do
105+
rel_into = "vendor"
106+
ff = vend.send(:write_compile_flags, rel_into, ["g"])
107+
flags = File.read(ff).strip
108+
check(flags == "--rbs vendor/sig", "spinel-flags carries project-relative --rbs (#{flags.inspect})")
109+
vend.send(:write_compile_flags, rel_into, [])
110+
check(File.read(ff).strip.empty?, "spinel-flags empty when no sig roots (cat-safe)")
111+
end
112+
end
113+
114+
# --- Vendorer: committed-sibling drift guard (audit gap 1) -------------------
115+
puts "\ndrift guard: a gem shipping lib/<X>/ + sig/<X>/ for an undeclared dep warns"
116+
Dir.mktmpdir("rbssib") do |src|
117+
# a producer (like tep) that hand-copied a sibling gem (like spinel_kit)
118+
FileUtils.mkdir_p(File.join(src, "lib", "spinel_kit"))
119+
FileUtils.mkdir_p(File.join(src, "sig", "spinel_kit"))
120+
File.write(File.join(src, "lib", "spinel_kit", "json.rb"), "module SpinelKit; end\n")
121+
File.write(File.join(src, "sig", "spinel_kit", "json.rbs"), "module SpinelKit\nend\n")
122+
# the producer's own tree (must NOT trip the guard)
123+
FileUtils.mkdir_p(File.join(src, "lib", "tep"))
124+
FileUtils.mkdir_p(File.join(src, "sig", "tep"))
125+
126+
vend = Bundler::Spinel::Vendorer.allocate
127+
Dep = Struct.new(:name) unless defined?(Dep)
128+
Spec = Struct.new(:name, :dependencies) unless defined?(Spec)
129+
130+
# case A: spinel_kit NOT declared -> warns
131+
spec_a = Spec.new("tep", [])
132+
w = vend.send(:committed_sibling_warnings, src, spec_a)
133+
check(w.size == 1 && w.first.include?("hand-copied `spinel_kit`"),
134+
"undeclared committed sibling -> 1 warning about spinel_kit (#{w.inspect})")
135+
check(w.none? { |s| s.include?("hand-copied `tep`") }, "own namespace (tep) not flagged")
136+
137+
# case B: spinel_kit declared as a dependency -> no warning (vendor manages it)
138+
spec_b = Spec.new("tep", [Dep.new("spinel_kit")])
139+
check(vend.send(:committed_sibling_warnings, src, spec_b).empty?,
140+
"declared dependency -> no warning")
141+
end
142+
143+
# a gem with lib/<X>/ but NO matching sig/<X>/ is not the copy fingerprint
144+
puts "\ndrift guard: lib-only sub-namespace (no sig/<X>/) is NOT flagged"
145+
Dir.mktmpdir("rbssib2") do |src|
146+
FileUtils.mkdir_p(File.join(src, "lib", "helpers"))
147+
FileUtils.mkdir_p(File.join(src, "sig"))
148+
File.write(File.join(src, "lib", "helpers", "x.rb"), "module Helpers; end\n")
149+
vend = Bundler::Spinel::Vendorer.allocate
150+
spec = Struct.new(:name, :dependencies).new("mygem", [])
151+
check(vend.send(:committed_sibling_warnings, src, spec).empty?,
152+
"lib/<X>/ without sig/<X>/ -> no warning")
101153
end
102154

103155
puts(@fails.zero? ? "\nall checks passed" : "\n#{@fails} check(s) FAILED")

0 commit comments

Comments
 (0)