bundler-spinel 0.0.1.pre
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 +7 -0
- data/ARCHITECTURE.md +164 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.md +84 -0
- data/RFC.md +120 -0
- data/exe/spinel-compat +4 -0
- data/lib/bundler/spinel/checker.rb +58 -0
- data/lib/bundler/spinel/cli.rb +225 -0
- data/lib/bundler/spinel/command.rb +37 -0
- data/lib/bundler/spinel/engine.rb +96 -0
- data/lib/bundler/spinel/gem_fetcher.rb +45 -0
- data/lib/bundler/spinel/ledger.rb +97 -0
- data/lib/bundler/spinel/platform.rb +24 -0
- data/lib/bundler/spinel/probe.rb +182 -0
- data/lib/bundler/spinel/proxy.rb +154 -0
- data/lib/bundler/spinel/survey.rb +130 -0
- data/lib/bundler/spinel/vendorer.rb +90 -0
- data/lib/bundler/spinel/verifier.rb +100 -0
- data/lib/bundler/spinel/version.rb +7 -0
- data/lib/bundler/spinel.rb +15 -0
- data/plugins.rb +6 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fedb059cdc7900cd98ccfff408a28e1fb4e4eaad68d9e7afe48c9d967cb97b71
|
|
4
|
+
data.tar.gz: a1d6fbfb31256d58cacab1cc120290455bd38de869295504a1c6dcdee96ea8da
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: acd75d23500e6af2d3e55c116bc0f5d82e8a4c8b1912049a349967e9cc920134ffe4820862abeeb6a753445f0a668cf26a9248c6aed4b983ea0dff7c262c13ee
|
|
7
|
+
data.tar.gz: 1bd2fa39872c215534c919f6a8c05cf01cf6dc30e5cce162d470d8d0ec3d750a09e06bc3a404d05f1ac4195a0c86232ca6e93f9d761e5d99771603361fd58756
|
data/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# bundler-spinel — architecture
|
|
2
|
+
|
|
3
|
+
Make Spinel gem-incompatibility fail **at `bundle lock` time**, not at compile
|
|
4
|
+
time (where Spinel may silently emit a no-op instead of an error) — and make
|
|
5
|
+
that verdict **forward-compatible**, so a gem rejected today flips to accepted
|
|
6
|
+
the moment a future Spinel learns the feature it needed.
|
|
7
|
+
|
|
8
|
+
## The problem it solves
|
|
9
|
+
|
|
10
|
+
Spinel is a whole-program Ruby→C AOT compiler with no gems, no eval, no
|
|
11
|
+
metaprogramming. Two facts make naive dependency management dangerous:
|
|
12
|
+
|
|
13
|
+
1. **Bundler can't gate compatibility.** There is no `required_ruby_engine`
|
|
14
|
+
gemspec field; `required_ruby_version` is engine-blind; you can't fabricate a
|
|
15
|
+
platform variant for a gem you don't publish. The Gemfile `engine: "spinel"`
|
|
16
|
+
directive is a *post-resolution* check — `bundle lock` ignores it (exit 0);
|
|
17
|
+
only `bundle install` fires the guard (exit 18). So resolution can't reject
|
|
18
|
+
an incompatible gem.
|
|
19
|
+
|
|
20
|
+
2. **Spinel doesn't fail loudly.** On unsupported Ruby it prints
|
|
21
|
+
`warning: ... cannot resolve call to 'eval' ... (emitting 0)` and degrades
|
|
22
|
+
the call to a no-op, exiting 0. Worse, some constructs (`define_method`,
|
|
23
|
+
silent miscompiles like local-var-name collapse / Int-0-as-nil) produce no
|
|
24
|
+
warning at all. So "it compiled" ≠ "it works."
|
|
25
|
+
|
|
26
|
+
This tool moves the decision to resolution time and grounds it in actually
|
|
27
|
+
running Spinel over the gem source.
|
|
28
|
+
|
|
29
|
+
## The ledger — single source of truth
|
|
30
|
+
|
|
31
|
+
`ledger/compat.jsonl`, append-only, one line per **`(gem, version, engine_rev)`**:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{"gem":"rake","version":"13.4.2","rev":"git:0adca86+dirty",
|
|
35
|
+
"verdict":"rejected","reasons":["analyze-failed"],
|
|
36
|
+
"risks":["eval","method_missing","needs:rbconfig"],"probe":"compile+scan","at":"…"}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**`engine_rev` is the forward-compat key.** Spinel ships no version string
|
|
40
|
+
(`spinel --version` prints usage; `git describe` is a bare SHA), so we key on the
|
|
41
|
+
git revision of the Spinel checkout, falling back to a binary content-hash.
|
|
42
|
+
Upgrade Spinel → new rev → cache miss → automatic re-probe. No hand-maintained
|
|
43
|
+
blocklist; a `rejected` verdict is always "rejected *as of this rev, because of
|
|
44
|
+
these named features*," never "rejected forever." `spinel-compat reprobe` sweeps
|
|
45
|
+
known gems under the current rev to surface what newly passes.
|
|
46
|
+
|
|
47
|
+
## The verdict ladder
|
|
48
|
+
|
|
49
|
+
Spinel's failure modes aren't exit codes, so one signal isn't enough:
|
|
50
|
+
|
|
51
|
+
| Verdict | Earned by | Trusted by |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `rejected` | unsupported **call** (`cannot resolve call to 'X'` → `unresolved:X`), or `analyze failed` / non-zero exit | gate fails the lock |
|
|
54
|
+
| `risky` | compiles clean, but static scan found constructs Spinel degrades silently (`define_method`, `eval`, `send`, `method_missing`, C-ext…) | gate allows; `--strict` fails |
|
|
55
|
+
| `clean` | compiles clean, no risky constructs | gate allows |
|
|
56
|
+
| `verified` | `clean` **and** the gem's own tests pass through a Spinel-compiled harness | curated whitelist + platform badge |
|
|
57
|
+
|
|
58
|
+
Two probe signals feed this:
|
|
59
|
+
|
|
60
|
+
- **Compile signal** (`spinel -c` over the gem's lib entrypoints) — parses stderr.
|
|
61
|
+
`cannot resolve call to 'X'` is the gold signal: precise and forward-compatible.
|
|
62
|
+
- **Static risk scan** — catches constructs that are silently degraded (no
|
|
63
|
+
warning) or hidden behind dead-code elimination.
|
|
64
|
+
|
|
65
|
+
### Honest limitations (why `verified` must exist)
|
|
66
|
+
|
|
67
|
+
- **No load path.** Spinel resolves plain `require "x"` only against
|
|
68
|
+
`<spinel>/lib`. A gem's internal `require "gem/part"` and its stdlib requires
|
|
69
|
+
don't resolve, so the compile probe doesn't fully follow multi-file gems
|
|
70
|
+
(recorded as `needs:X` notes, not rejections). The compile signal is a **lower
|
|
71
|
+
bound** on problems. `require_relative`-based gems probe well; plain-`require`
|
|
72
|
+
gems under-probe.
|
|
73
|
+
- **Silent miscompiles** (var-name collapse, Int-0-as-nil) emit no warning and
|
|
74
|
+
exit 0. The static scan can't see them either.
|
|
75
|
+
|
|
76
|
+
So `clean` means "no problem *found cheaply*," not "correct." Only `verified` —
|
|
77
|
+
compiling the gem's test suite and running it — is trustworthy, which is exactly
|
|
78
|
+
why the curated whitelist and the platform badge require it.
|
|
79
|
+
|
|
80
|
+
## Placement — "make it work" (the primary job)
|
|
81
|
+
|
|
82
|
+
Independent of the ledger: `spinel-compat vendor` reads a `Gemfile.lock`, copies
|
|
83
|
+
each gem's `lib/` into `vendor/spinel/<name>/`, and writes `vendor/spinel/deps.rb`
|
|
84
|
+
(a `require_relative` manifest in lock order). A Spinel program does
|
|
85
|
+
`require_relative "vendor/spinel/deps"`. This is the reusable form of the
|
|
86
|
+
hand-vendoring projects do today, and the reason the convention is usable at all
|
|
87
|
+
given Spinel has no load path. Gating is layered on top but advisory — placement
|
|
88
|
+
and compatibility are different jobs.
|
|
89
|
+
|
|
90
|
+
## Gating consumers, all views over the ledger
|
|
91
|
+
|
|
92
|
+
### 1. Lock-time gate — `bundle spinel-lock` (BUILT)
|
|
93
|
+
`bundle lock` (resolves, ignoring the engine directive), then `check` resolves a
|
|
94
|
+
verdict for every locked gem (ledger hit, or probe-on-miss) and exits non-zero on
|
|
95
|
+
any `rejected`. The headline: resolution-time failure with feature-named reasons.
|
|
96
|
+
Also the **backstop** for consumer #2's leak (below): it re-checks every gem in
|
|
97
|
+
the *resulting* lock against the ledger regardless of where it resolved from.
|
|
98
|
+
|
|
99
|
+
### 1b. The `verified` rung — `spinel-compat verify` (BUILT)
|
|
100
|
+
Differential testing: run a smoke program once under CRuby and once Spinel-
|
|
101
|
+
compiled, diff stdout. The only signal that catches Spinel's **silent
|
|
102
|
+
miscompiles** — they emit no warning and exit 0, so the cheap probe calls them
|
|
103
|
+
`clean`, but a differential run diverges. Proven: a method doing `h[k].nil? ? -1
|
|
104
|
+
: v` over a stored `0` is `clean` to the probe but caught as `rejected:miscompile`
|
|
105
|
+
by verify (`L2 cruby="-1" spinel="0"` — the Int-0-as-nil footgun). The smoke is
|
|
106
|
+
the unit of trust, so `verified` is opt-in and smoke-supplied.
|
|
107
|
+
|
|
108
|
+
### 2. Curated source / proxy — `spinel-compat serve` (BUILT, MVP)
|
|
109
|
+
A Compact Index source serving only vetted gems from a local store of .gem
|
|
110
|
+
artifacts (`--min verified|clean|risky`). Proven end-to-end: `bundle lock` against
|
|
111
|
+
it resolves a vetted gem (exit 0) and fails on an absent one (exit 7, "could not
|
|
112
|
+
find gem … in repository … or installed locally"). The "whitelist" is not a file:
|
|
113
|
+
it's the acceptable-verdict subset of the ledger at the pinned rev. `path:`/`git:`
|
|
114
|
+
siblings (`gem "tep", path: …`) are the degenerate one-gem curated source and
|
|
115
|
+
probe in place via `probe --dir`.
|
|
116
|
+
|
|
117
|
+
**Leak caveat (important):** Bundler *also* considers locally-installed gems
|
|
118
|
+
("…or installed locally"). In a polluted environment an unvetted-but-installed
|
|
119
|
+
gem can resolve and even be mis-attributed to the source. So the curated source
|
|
120
|
+
gates a **clean** environment (CI); pair it with consumer #1 as a backstop for
|
|
121
|
+
dev machines. Read-through filtering of upstream rubygems is a documented
|
|
122
|
+
extension; empirically the third-party ecosystem is ~all-rejected today, so the
|
|
123
|
+
local-store mode is what carries weight.
|
|
124
|
+
|
|
125
|
+
### 3. Platform-variant opt-in (designed — `platform.rb` stub)
|
|
126
|
+
The bridge from "our ledger says verified" to "stock Bundler selects it,"
|
|
127
|
+
reusing the same machinery JRuby uses with the `java` platform. A `verified` gem
|
|
128
|
+
is republished (to the curated source) under a `spinel` platform token — likely
|
|
129
|
+
`<cpu>-spinel-<engine_rev>` so the badge is rev-scoped — and Bundler's normal
|
|
130
|
+
platform resolution prefers it. Opt-in because earning the badge means someone
|
|
131
|
+
ran the gem's tests through a Spinel-compiled harness.
|
|
132
|
+
|
|
133
|
+
## Dogfooding — the curated source served by Tep
|
|
134
|
+
|
|
135
|
+
The MVP proxy is CRuby/WEBrick (fast to prove Bundler resolves against it). The
|
|
136
|
+
target is to serve the same Compact Index endpoints from **Tep** — the Sinatra-
|
|
137
|
+
flavoured framework that *itself compiles via Spinel*. Then the Spinel
|
|
138
|
+
dependency-manager's source is a Spinel program: the system vets its own
|
|
139
|
+
substrate. Tep already has the pieces (`sphttp` server, routing, an HTTP client
|
|
140
|
+
for upstream fetch); the open questions are Spinel support for the bits the proxy
|
|
141
|
+
needs — MD5/SHA256 (digest), and reading the JSONL ledger — which are themselves
|
|
142
|
+
good probe targets. Serving `/names`, `/versions`, `/info/<gem>`, `/gems/<file>`
|
|
143
|
+
is plain text + file bytes, well within Tep's range.
|
|
144
|
+
|
|
145
|
+
## Layout
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
lib/bundler/spinel/
|
|
149
|
+
engine.rb # locate compiler, derive forward-compat engine rev
|
|
150
|
+
ledger.rb # append-only JSONL verdict store, keyed on (gem,version,rev)
|
|
151
|
+
gem_fetcher.rb # gem fetch + unpack (source, not install), cached
|
|
152
|
+
probe.rb # compile signal + static risk scan -> verdict
|
|
153
|
+
verifier.rb # differential CRuby-vs-Spinel smoke -> verified
|
|
154
|
+
vendorer.rb # place lockfile deps where Spinel finds them + deps.rb
|
|
155
|
+
survey.rb # parallel wholesale review -> reason histogram
|
|
156
|
+
checker.rb # Gemfile.lock -> per-gem verdict -> pass/fail gate
|
|
157
|
+
cli.rb # `spinel-compat` dispatcher
|
|
158
|
+
command.rb # Bundler plugin command (bundle spinel-lock / -check)
|
|
159
|
+
proxy.rb # STUB: curated source
|
|
160
|
+
platform.rb # STUB: platform-variant opt-in
|
|
161
|
+
exe/spinel-compat # standalone CLI
|
|
162
|
+
plugins.rb # Bundler plugin entry
|
|
163
|
+
ledger/compat.jsonl
|
|
164
|
+
```
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `bundler-spinel` are documented here. The format
|
|
4
|
+
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
|
|
5
|
+
project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.0.1.pre] — 2026-05-26
|
|
8
|
+
|
|
9
|
+
First pre-release. **Experimental** — the CLI surface, the verdict vocabulary,
|
|
10
|
+
and the ledger format may all change before `0.0.1`. Install with `--pre`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial public release: the Gemfile convention for Spinel projects, the
|
|
14
|
+
`spinel-compat` CLI (`vendor`, `check`, `probe`, `verify`, `survey`,
|
|
15
|
+
`serve`, `ledger`, `reprobe`), and the `spinel-lock` / `spinel-check`
|
|
16
|
+
Bundler plugin commands.
|
|
17
|
+
- Forward-compatible, engine-rev-keyed compatibility ledger.
|
|
18
|
+
- Wholesale `survey` with a thread-safe ledger, a per-compile wall-clock
|
|
19
|
+
timeout (`analyze-timeout` reject reason), and a ledger-based report.
|
|
20
|
+
|
|
21
|
+
[0.0.1.pre]: https://github.com/OriPekelman/spinelgems/releases/tag/v0.0.1.pre
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ori Pekelman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# bundler-spinel
|
|
2
|
+
|
|
3
|
+
> ⚠️ **Pre-release / experimental** (`0.0.1.pre`). The CLI surface, the verdict
|
|
4
|
+
> vocabulary, and the ledger format may all change before `0.0.1`. Install with
|
|
5
|
+
> `--pre`. (`spinelgems.org` is not live yet.)
|
|
6
|
+
|
|
7
|
+
**Use a standard `Gemfile` for your [Spinel](https://github.com/matz/spinel)
|
|
8
|
+
project — for now.** Spinel-compiled projects have no shared way to declare or
|
|
9
|
+
exchange dependencies, so each vendors by hand. Rather than design a package
|
|
10
|
+
manager, borrow a format everyone already knows and revisit later. See
|
|
11
|
+
[RFC.md](RFC.md) for the proposal.
|
|
12
|
+
|
|
13
|
+
`bundler-spinel` is a small Bundler plugin that makes that practical in two ways:
|
|
14
|
+
|
|
15
|
+
1. **Makes it work** — places resolved dependencies where Spinel can actually
|
|
16
|
+
find them. Spinel has no load path and inlines `require_relative`, so a dep
|
|
17
|
+
has to be *placed* and wired. `spinel-compat vendor` does that from a lockfile.
|
|
18
|
+
2. **Gates** — Spinel silently emits a no-op for unsupported Ruby (exit 0), so
|
|
19
|
+
"it compiled" ≠ "it works". The plugin probes gems and flags incompatible ones
|
|
20
|
+
at `bundle lock` time, with reasons that name the missing feature — nicer than
|
|
21
|
+
a silent miscompile. Verdicts are forward-compatible (keyed on the Spinel rev).
|
|
22
|
+
|
|
23
|
+
## The Gemfile convention
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
source "https://rubygems.org"
|
|
27
|
+
ruby "3.3.0", engine: "spinel", engine_version: "0.0.0"
|
|
28
|
+
|
|
29
|
+
gem "tep", git: "https://…/tep.git" # siblings via path:/git: (replaces rsync)
|
|
30
|
+
gem "some_pure_ruby_lib"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`bundle lock` resolves normally (it ignores the engine marker); the marker guards
|
|
34
|
+
`bundle install` (exit 18 under CRuby). Nothing here is novel — that's the point.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
# 1. make it work — place deps where Spinel finds them
|
|
40
|
+
bundle lock
|
|
41
|
+
exe/spinel-compat vendor # -> vendor/spinel/<gem>/lib + vendor/spinel/deps.rb
|
|
42
|
+
# then `require_relative "vendor/spinel/deps"` from your Spinel entrypoint
|
|
43
|
+
|
|
44
|
+
# 2. gate — flag what Spinel can't compile, early
|
|
45
|
+
exe/spinel-compat check Gemfile.lock # exit 1 if any gem is rejected
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
As a Bundler plugin:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
bundle plugin install bundler-spinel --git https://github.com/OriPekelman/spinelgems.git # or --path .
|
|
52
|
+
bundle spinel-lock # bundle lock, then report incompatible gems
|
|
53
|
+
bundle spinel-check # gate an existing Gemfile.lock
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## The rest of the toolbelt
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
exe/spinel-compat engine # detected compiler + engine rev
|
|
60
|
+
exe/spinel-compat probe rake [--dir P] # probe one gem (or a local/sibling dir)
|
|
61
|
+
exe/spinel-compat verify NAME --smoke F # differential CRuby-vs-Spinel run -> verified
|
|
62
|
+
exe/spinel-compat survey --list F # wholesale review -> reason histogram
|
|
63
|
+
exe/spinel-compat serve --store DIR # curated source (only vetted gems)
|
|
64
|
+
exe/spinel-compat ledger / reprobe # inspect / re-probe under current rev
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Verdicts: `✓ clean` · `★ verified` · `~ risky` · `✗ rejected`.
|
|
68
|
+
|
|
69
|
+
## Environment
|
|
70
|
+
|
|
71
|
+
- `SPINEL_DIR` — path to the Spinel checkout (default `~/spinel`; falls back to a `spinel` on `PATH`).
|
|
72
|
+
- `SPINEL_COMPAT_LEDGER` — ledger path (default `ledger/compat.jsonl`).
|
|
73
|
+
|
|
74
|
+
## Status
|
|
75
|
+
|
|
76
|
+
Working: the Gemfile convention, `vendor` (placement), the lock-time gate +
|
|
77
|
+
Bundler plugin, the probe + forward-compat ledger, the `verified` differential
|
|
78
|
+
harness, the curated source (`serve`), and the wholesale `survey`.
|
|
79
|
+
|
|
80
|
+
The probe is a **lower bound** — Spinel's lack of a load path means multi-file
|
|
81
|
+
plain-`require` gems under-probe, and silent miscompiles are invisible to it.
|
|
82
|
+
Trust `verified` (smoke runs identically under CRuby and Spinel), not `clean`,
|
|
83
|
+
where it matters. Empirically most third-party gems reject today, so the weight
|
|
84
|
+
is on your own vetted gems and `path:`/`git:` siblings — not a rubygems mirror.
|
data/RFC.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# RFC: Use a Gemfile for Spinel projects (for now)
|
|
2
|
+
|
|
3
|
+
**Status:** draft (for discussion)
|
|
4
|
+
**Author:** Ori Pekelman
|
|
5
|
+
**Prototype:** [`bundler-spinel`](./README.md) — working, see [ARCHITECTURE.md](./ARCHITECTURE.md)
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
|
|
9
|
+
Spinel-compiled projects have no shared way to declare or exchange dependencies,
|
|
10
|
+
so each one vendors by hand. This RFC proposes a deliberately small, temporary
|
|
11
|
+
answer that requires **no new design**:
|
|
12
|
+
|
|
13
|
+
1. **Declare dependencies in a standard `Gemfile`.** Mark the project with
|
|
14
|
+
`ruby "3.x", engine: "spinel", engine_version: "0.0.0"`. That's the whole
|
|
15
|
+
convention. It buys a familiar manifest, `path:`/`git:` sources for sibling
|
|
16
|
+
projects, and Bundler's resolver + lockfile — for free.
|
|
17
|
+
|
|
18
|
+
2. **A small Bundler plugin** ([`bundler-spinel`](./README.md)) that does two
|
|
19
|
+
things: **(a) makes it work** — places resolved dependencies where Spinel can
|
|
20
|
+
actually find them; and **(b) gates** — flags gems Spinel can't compile early,
|
|
21
|
+
so the experience is nicer than a silent miscompile.
|
|
22
|
+
|
|
23
|
+
The point is to **postpone the big discussion**. Spinel isn't near a release, and
|
|
24
|
+
there's no expectation it adopts any particular dependency-management design. But
|
|
25
|
+
projects need *some* interop today — so let's borrow a format everyone knows and
|
|
26
|
+
revisit later, rather than design a package manager now.
|
|
27
|
+
|
|
28
|
+
## Motivation
|
|
29
|
+
|
|
30
|
+
Interop is already happening, ad-hoc. Roundhouse (a separate author's project)
|
|
31
|
+
vendors part of Tep; our own projects carry a mix of rsync'd copies and hand-
|
|
32
|
+
maintained concatenation. Every project reinvents "get this other code into a
|
|
33
|
+
shape Spinel will compile." A shared, boring convention removes that duplication
|
|
34
|
+
without anyone committing to a long-term model.
|
|
35
|
+
|
|
36
|
+
Two facts make a thin tool worthwhile (both verified — Bundler 2.7.2 / CRuby):
|
|
37
|
+
|
|
38
|
+
- **Bundler already does the right thing with the engine marker.** `bundle lock`
|
|
39
|
+
ignores `engine: "spinel"` and resolves normally (exit 0); only `bundle
|
|
40
|
+
install` fires the guard ("Your Ruby engine is ruby, but your Gemfile specified
|
|
41
|
+
spinel", exit 18). So the convention costs nothing and fails loudly in the
|
|
42
|
+
right place.
|
|
43
|
+
|
|
44
|
+
- **Spinel doesn't fail loudly on unsupported Ruby.** It prints
|
|
45
|
+
`warning: … cannot resolve call to 'eval' … (emitting 0)` and degrades the call
|
|
46
|
+
to a no-op, exiting **0**; some constructs (and silent miscompiles) warn not at
|
|
47
|
+
all. So "it compiled" ≠ "it works" — which is why a little gating help is worth
|
|
48
|
+
having.
|
|
49
|
+
|
|
50
|
+
## Proposal 1 — the Gemfile convention
|
|
51
|
+
|
|
52
|
+
A Spinel project's `Gemfile`:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
source "https://rubygems.org"
|
|
56
|
+
ruby "3.3.0", engine: "spinel", engine_version: "0.0.0"
|
|
57
|
+
|
|
58
|
+
gem "tep", git: "https://…/tep.git" # sibling projects via path:/git:
|
|
59
|
+
gem "some_pure_ruby_lib" # third-party, if it compiles
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Nothing here is novel — that's the point. `bundle lock` produces a normal
|
|
63
|
+
lockfile; siblings resolve through `path:`/`git:` sources (replacing rsync /
|
|
64
|
+
manual vendoring); third-party gems resolve from rubygems.org. The engine marker
|
|
65
|
+
documents the target and guards `bundle install`.
|
|
66
|
+
|
|
67
|
+
## Proposal 2 — the Bundler plugin
|
|
68
|
+
|
|
69
|
+
### (a) Make it work — placement
|
|
70
|
+
|
|
71
|
+
Spinel has no load path (plain `require "x"` resolves only against
|
|
72
|
+
`<spinel>/lib`) and inlines `require_relative`. So a resolved dependency has to
|
|
73
|
+
be *placed* where Spinel will follow it. The plugin vendors each locked gem's
|
|
74
|
+
`lib/` into a project dir and generates a `require_relative` manifest:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
spinel-compat vendor # reads Gemfile.lock
|
|
78
|
+
# -> vendor/spinel/<gem>/lib/… and vendor/spinel/deps.rb
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A Spinel program then just `require_relative "vendor/spinel/deps"`. This is the
|
|
82
|
+
reusable form of what projects already do by hand (concatenation scripts, partial
|
|
83
|
+
vendoring).
|
|
84
|
+
|
|
85
|
+
### (b) Gating — a nicer experience
|
|
86
|
+
|
|
87
|
+
Because Spinel silently no-ops unsupported Ruby, the plugin probes gems (compile
|
|
88
|
+
+ static scan, optionally a differential CRuby-vs-Spinel run) and flags ones that
|
|
89
|
+
won't work — at `bundle lock` time, with reasons that name the missing feature:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
bundle spinel-lock # bundle lock, then report incompatible gems
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Verdicts are **forward-compatible**: each is keyed on the Spinel revision (Spinel
|
|
96
|
+
ships no version, so we key on the git rev / binary hash, scoped by platform).
|
|
97
|
+
Upgrade Spinel → re-probe → a gem rejected today clears the moment the feature it
|
|
98
|
+
needed lands. No hand-maintained blocklist.
|
|
99
|
+
|
|
100
|
+
Gating is advisory by design — placement and compatibility are different jobs.
|
|
101
|
+
The plugin makes the failure visible and early; it doesn't try to be a wall.
|
|
102
|
+
|
|
103
|
+
## Open questions (for discussion, not asks)
|
|
104
|
+
|
|
105
|
+
These are things we ran into, offered as discussion points — not feature
|
|
106
|
+
requests, and not assuming Spinel wants any of them:
|
|
107
|
+
|
|
108
|
+
- **Signalling unsupported constructs.** Today they're silent no-ops at exit 0.
|
|
109
|
+
Would a non-zero exit or a structured (e.g. JSON) diagnostic — perhaps behind a
|
|
110
|
+
`--check`/`--strict` flag — be in scope or interest? It's the difference
|
|
111
|
+
between inferring compatibility from stderr and reading it from a contract.
|
|
112
|
+
- **A stable engine identity.** We key verdicts on a git rev / binary hash for
|
|
113
|
+
now. If Spinel ever wanted to expose a version/rev, the ledger and the
|
|
114
|
+
`engine_version:` marker could line up — but there's no rush.
|
|
115
|
+
- **Resolving `require` beyond `<spinel>/lib`.** A load-path notion would let
|
|
116
|
+
multi-file gems and stdlib shims resolve (and would make probing more
|
|
117
|
+
accurate). Possibly useful well beyond this tool; possibly out of scope.
|
|
118
|
+
|
|
119
|
+
None of these are needed for the convention or the plugin to be useful today.
|
|
120
|
+
They're just where the seams are, if there's ever appetite to smooth them.
|
data/exe/spinel-compat
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "bundler"
|
|
2
|
+
require "bundler/lockfile_parser"
|
|
3
|
+
|
|
4
|
+
module Bundler
|
|
5
|
+
module Spinel
|
|
6
|
+
# The resolution-time gate. Reads a Gemfile.lock, resolves a verdict for
|
|
7
|
+
# every locked gem (from the ledger, or by probing on a cache miss), and
|
|
8
|
+
# decides pass/fail. This is what turns Spinel's compile-time-or-never
|
|
9
|
+
# failure into a `bundle lock`-time failure.
|
|
10
|
+
class Checker
|
|
11
|
+
Result = Struct.new(:verdict, :rejected, :risky, :probed, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def initialize(engine: Engine.new, ledger: Ledger.new)
|
|
14
|
+
@engine = engine
|
|
15
|
+
@ledger = ledger
|
|
16
|
+
@fetcher = GemFetcher.new
|
|
17
|
+
@probe = Probe.new(@engine, @ledger)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# strict: treat `risky` as a failure too.
|
|
21
|
+
def check(lockfile = "Gemfile.lock", strict: false)
|
|
22
|
+
@engine.ensure!
|
|
23
|
+
parsed = Bundler::LockfileParser.new(File.read(lockfile))
|
|
24
|
+
rejected = []
|
|
25
|
+
risky = []
|
|
26
|
+
verdicts = []
|
|
27
|
+
|
|
28
|
+
parsed.specs.each do |spec|
|
|
29
|
+
# path:/git: sources (e.g. a sibling like tep) probe in place; the
|
|
30
|
+
# GEM source fetches. We only have name+version here, so prototype
|
|
31
|
+
# handles the rubygems case; path/git probing is a documented TODO.
|
|
32
|
+
v = verdict_for(spec.name, spec.version.to_s)
|
|
33
|
+
next unless v # skipped (unfetchable / TODO source)
|
|
34
|
+
|
|
35
|
+
verdicts << v
|
|
36
|
+
rejected << v if v.rejected?
|
|
37
|
+
risky << v if v.risky?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
ok = rejected.empty? && (!strict || risky.empty?)
|
|
41
|
+
Result.new(verdict: ok, rejected: rejected, risky: risky, probed: verdicts)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def verdict_for(name, version)
|
|
47
|
+
cached = @ledger.lookup(name, version, @engine.rev)
|
|
48
|
+
return cached if cached
|
|
49
|
+
|
|
50
|
+
dir = @fetcher.fetch(name, version)
|
|
51
|
+
@probe.probe(name, version, dir)
|
|
52
|
+
rescue Error => e
|
|
53
|
+
warn "[spinel-compat] skipped #{name} #{version}: #{e.message}"
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|