bundler-spinel 0.0.1.pre → 0.1.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 +4 -4
- data/ARCHITECTURE.md +7 -1
- data/CHANGELOG.md +40 -0
- data/README.md +67 -61
- data/lib/bundler/spinel/checker.rb +17 -6
- data/lib/bundler/spinel/cli.rb +216 -10
- data/lib/bundler/spinel/engine.rb +17 -4
- data/lib/bundler/spinel/engine_installer.rb +235 -0
- data/lib/bundler/spinel/enricher.rb +110 -0
- data/lib/bundler/spinel/ext_detector.rb +126 -0
- data/lib/bundler/spinel/gem_fetcher.rb +5 -1
- data/lib/bundler/spinel/history.rb +147 -0
- data/lib/bundler/spinel/ledger.rb +8 -2
- data/lib/bundler/spinel/probe.rb +64 -1
- data/lib/bundler/spinel/proxy.rb +7 -1
- data/lib/bundler/spinel/server.rb +52 -0
- data/lib/bundler/spinel/site.rb +541 -0
- data/lib/bundler/spinel/survey.rb +138 -22
- data/lib/bundler/spinel/test_runner.rb +177 -0
- data/lib/bundler/spinel/vendorer.rb +172 -3
- data/lib/bundler/spinel/verifier.rb +113 -19
- data/lib/bundler/spinel/version.rb +5 -3
- data/lib/bundler/spinel.rb +1 -0
- metadata +13 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99f01821538cfb0ce86a06e2ecb1251f89e1086530c59423715f509d18e0c717
|
|
4
|
+
data.tar.gz: ca1c456f3bf3c4d9e4424a0466daa23bb4495b64357adfed8a69145953d54a06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fa08674c1d4206f15713004a9452d3aba7b43c2f6a1997c5c0d6d97ac7bbe00a4b2a986eb50673518acebcf807cab58ad785916aa1c3d3c71dab939d1e4c9163
|
|
7
|
+
data.tar.gz: 41b3407d8dd4d7f115a50c3294e0e0204e9c3c719ee52042876245ff0a63fa1cfd4b1e2ba8b97037d94ba27a2f25c25f893660999b306ce912663e050ed674d6
|
data/ARCHITECTURE.md
CHANGED
|
@@ -53,7 +53,13 @@ Spinel's failure modes aren't exit codes, so one signal isn't enough:
|
|
|
53
53
|
| `rejected` | unsupported **call** (`cannot resolve call to 'X'` → `unresolved:X`), or `analyze failed` / non-zero exit | gate fails the lock |
|
|
54
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
55
|
| `clean` | compiles clean, no risky constructs | gate allows |
|
|
56
|
-
| `
|
|
56
|
+
| `loaded` | `clean` **and** a require-only differential run loads identically under CRuby and Spinel — but its logic was never exercised, so a silent miscompile there is still possible | gate allows; **not** trusted by the curated source |
|
|
57
|
+
| `verified` | `loaded` **and** a behaviour smoke (drives the gem's API) runs identically under CRuby and a Spinel-compiled harness | curated whitelist + platform badge |
|
|
58
|
+
|
|
59
|
+
The `loaded`/`verified` split is empirical: gems that load identically still
|
|
60
|
+
silently miscompile in untested logic (`strings-ansi`'s `sanitize` → `"0"`,
|
|
61
|
+
`semantic_puppet`'s `1.2.3 < 1.10.0` → `false`). Only a behaviour smoke catches
|
|
62
|
+
it — see `harness/`.
|
|
57
63
|
|
|
58
64
|
Two probe signals feed this:
|
|
59
65
|
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,45 @@ 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.1.0] — 2026-06-01
|
|
8
|
+
|
|
9
|
+
First non-prerelease. Installable without `--pre`. Closes the onboarding gap
|
|
10
|
+
(spinelgems#9): a newcomer can `gem install bundler-spinel` and go from nothing
|
|
11
|
+
to a compiled Spinel app without an out-of-band `git clone matz/spinel && make`.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **`spinel-compat install-engine [REV]`** — provisions the Spinel compiler
|
|
15
|
+
itself: fetch matz/spinel at a pinned revision (arg › `SPINEL_PIN` file ›
|
|
16
|
+
default) → `make deps && make all` → cache under `~/.cache/spinel/<rev>/` →
|
|
17
|
+
point a `current` symlink at it. Idempotent, offline-after-first-build;
|
|
18
|
+
`--force` rebuilds. `Engine` now resolves that cache after `SPINEL_DIR` and
|
|
19
|
+
before PATH, so a provisioned engine is found with zero further configuration.
|
|
20
|
+
- **`spinel-compat init [DIR]`** — scaffolds a minimal Spinel + Tep project
|
|
21
|
+
(`Gemfile` with the engine marker + `gem "tep"`, a hello `app.rb`, a
|
|
22
|
+
`bin/build` that runs `install-engine` + `vendor` + `tep build`). Onboarding
|
|
23
|
+
becomes `bundle install && spinel-compat init && bin/build`.
|
|
24
|
+
- `spinel-compat verify --full`: force-requires every `lib/` file (no `autoload`
|
|
25
|
+
masking, no `LoadError` rescue) so verification covers the gem's whole surface,
|
|
26
|
+
not just the entrypoint. Ledger probe `verify-full`.
|
|
27
|
+
- **Composable signal badges** on the catalog: `👤 human` (version-pinned
|
|
28
|
+
attestations), `✪ tests` (the gem's own suite passes under Spinel,
|
|
29
|
+
`verify --tests`), and a surfaced `rubric` tag (*why* a non-verified gem isn't
|
|
30
|
+
there yet). See `docs/verification-tiers.md`.
|
|
31
|
+
- `docs/verification-tiers.md`: the trust ladder, the full-surface bar, the
|
|
32
|
+
badge model, and the matz/spinel bug pipeline.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- **`verified` now means full-surface.** The catalog grants ★ only to gems with
|
|
36
|
+
a `verify-full` match — an entrypoint-only/constant smoke no longer qualifies
|
|
37
|
+
(it overstated usability; see the qdrant-ruby spike, spinelgems#4).
|
|
38
|
+
- `GemFetcher` honours `SPINEL_COMPAT_CACHE` to relocate the source cache off a
|
|
39
|
+
tight root fs.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
- `vendor`: a split `@MOD_O@` / `@MOD_CFLAGS@` C-extension pair now associates by
|
|
43
|
+
module name, so the source compile gets the sibling's `pkg_config` include
|
|
44
|
+
path (spinelgems#8 — tep pg → `libpq-fe.h`).
|
|
45
|
+
|
|
7
46
|
## [0.0.1.pre] — 2026-05-26
|
|
8
47
|
|
|
9
48
|
First pre-release. **Experimental** — the CLI surface, the verdict vocabulary,
|
|
@@ -18,4 +57,5 @@ and the ledger format may all change before `0.0.1`. Install with `--pre`.
|
|
|
18
57
|
- Wholesale `survey` with a thread-safe ledger, a per-compile wall-clock
|
|
19
58
|
timeout (`analyze-timeout` reject reason), and a ledger-based report.
|
|
20
59
|
|
|
60
|
+
[0.1.0]: https://github.com/OriPekelman/spinelgems/releases/tag/v0.1.0
|
|
21
61
|
[0.0.1.pre]: https://github.com/OriPekelman/spinelgems/releases/tag/v0.0.1.pre
|
data/README.md
CHANGED
|
@@ -1,24 +1,35 @@
|
|
|
1
1
|
# bundler-spinel
|
|
2
2
|
|
|
3
|
-
> ⚠️ **Pre-release / experimental** (`0.
|
|
4
|
-
> vocabulary, and the ledger format may
|
|
5
|
-
>
|
|
6
|
-
|
|
7
|
-
**Use a standard `Gemfile` for your [Spinel](https://github.com/matz/spinel)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
3
|
+
> ⚠️ **Pre-release / experimental** (`0.1.0`). The CLI surface, the verdict
|
|
4
|
+
> vocabulary, and the ledger format may still change. Browse the live catalog at
|
|
5
|
+
> **<https://spinelgems.org>**.
|
|
6
|
+
|
|
7
|
+
**Use a standard `Gemfile` for your [Spinel](https://github.com/matz/spinel) project.**
|
|
8
|
+
|
|
9
|
+
[Spinel](https://github.com/matz/spinel) is a new ahead-of-time Ruby compiler.
|
|
10
|
+
Spinel projects have no shared way to declare or exchange dependencies, so each
|
|
11
|
+
vendors by hand. Rather than invent a package manager, we propose a plain
|
|
12
|
+
`Gemfile` — for two reasons:
|
|
13
|
+
|
|
14
|
+
- **Share Spinel code.** A `Gemfile` (with `path:` / `git:` siblings) becomes the
|
|
15
|
+
preferred way to declare and exchange dependencies *between* Spinel projects.
|
|
16
|
+
- **Reach the Ruby ecosystem.** The same `Gemfile` lets a Spinel project pull from
|
|
17
|
+
the enormous existing body of Ruby gems.
|
|
18
|
+
|
|
19
|
+
The catch: **most gems won't compile under Spinel yet.** Its scope is deliberately
|
|
20
|
+
limited and still growing. So we surveyed the ecosystem and publish a
|
|
21
|
+
**[catalog](https://spinelgems.org)** of what works today, at each engine revision.
|
|
22
|
+
|
|
23
|
+
## The bundler plugin
|
|
24
|
+
|
|
25
|
+
`bundler-spinel` makes the convention practical in two ways:
|
|
26
|
+
|
|
27
|
+
1. **Makes it work** — Spinel has no load path and inlines `require_relative`, so a
|
|
28
|
+
dependency has to be *placed* where Spinel will follow it. `spinel-compat vendor`
|
|
29
|
+
does that from a lockfile.
|
|
30
|
+
2. **Gates** — Spinel silently no-ops unsupported Ruby (exit 0), so "it compiled"
|
|
31
|
+
≠ "it works". The plugin probes gems and flags incompatible ones at `bundle lock`
|
|
32
|
+
time, with reasons that name the missing feature.
|
|
22
33
|
|
|
23
34
|
## The Gemfile convention
|
|
24
35
|
|
|
@@ -26,59 +37,54 @@ manager, borrow a format everyone already knows and revisit later. See
|
|
|
26
37
|
source "https://rubygems.org"
|
|
27
38
|
ruby "3.3.0", engine: "spinel", engine_version: "0.0.0"
|
|
28
39
|
|
|
29
|
-
gem "tep", git: "https://…/tep.git" # siblings via path:/git:
|
|
40
|
+
gem "tep", git: "https://…/tep.git" # siblings via path:/git:
|
|
30
41
|
gem "some_pure_ruby_lib"
|
|
31
42
|
```
|
|
32
43
|
|
|
33
44
|
`bundle lock` resolves normally (it ignores the engine marker); the marker guards
|
|
34
|
-
`bundle install
|
|
45
|
+
`bundle install`. Nothing here is novel — that's the point.
|
|
35
46
|
|
|
36
47
|
## Quick start
|
|
37
48
|
|
|
38
49
|
```sh
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
50
|
+
gem install bundler-spinel
|
|
51
|
+
spinel-compat install-engine # fetch + build the Spinel compiler (cached)
|
|
52
|
+
spinel-compat init my_app # scaffold a Gemfile + app.rb + bin/build
|
|
53
|
+
cd my_app && bundle install && ./bin/build
|
|
46
54
|
```
|
|
47
55
|
|
|
48
|
-
|
|
56
|
+
Or add the gate to an existing project:
|
|
49
57
|
|
|
50
58
|
```sh
|
|
51
|
-
bundle plugin install bundler-spinel
|
|
52
|
-
bundle spinel-lock
|
|
53
|
-
|
|
59
|
+
bundle plugin install bundler-spinel
|
|
60
|
+
bundle spinel-lock # bundle lock + report incompatible gems
|
|
61
|
+
spinel-compat vendor # place deps -> vendor/spinel/<gem>/lib + deps.rb
|
|
62
|
+
spinel-compat check Gemfile.lock # gate: exit 1 if any gem is rejected
|
|
54
63
|
```
|
|
55
64
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
harness
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
65
|
+
Then `require_relative "vendor/spinel/deps"` from your Spinel entrypoint.
|
|
66
|
+
|
|
67
|
+
> **The compiler builds from source.** `spinel-compat install-engine` clones
|
|
68
|
+
> [Spinel](https://github.com/matz/spinel) and runs `make` (a few minutes, once
|
|
69
|
+
> per revision; needs `git` + `make` + a C compiler), caching the result under
|
|
70
|
+
> `~/.cache/spinel/`. This will become near-instant once there are **prebuilt
|
|
71
|
+
> binaries per platform** — but Spinel is pre-release and moving fast (no stable
|
|
72
|
+
> tags yet, revisions land daily), so building from source is deliberate for now:
|
|
73
|
+
> it's portable, needs no release pipeline, and always matches the *exact* engine
|
|
74
|
+
> revision your compatibility verdicts are keyed on. Prebuilts come once the
|
|
75
|
+
> engine stabilizes; we're not there yet.
|
|
76
|
+
|
|
77
|
+
Verdicts: `★ verified` · `○ loaded` · `✓ clean` · `~ risky` · `✗ rejected`. Trust
|
|
78
|
+
`verified` (a behaviour smoke matches CRuby); `clean`/`loaded` are cheap lower
|
|
79
|
+
bounds. [What the verdicts mean →](https://spinelgems.org)
|
|
80
|
+
|
|
81
|
+
## More
|
|
82
|
+
|
|
83
|
+
- [RFC.md](RFC.md) — the proposal.
|
|
84
|
+
- [docs/cli.md](docs/cli.md) — the full `spinel-compat` toolbelt, verdict ladder, env vars.
|
|
85
|
+
- [docs/adoption.md](docs/adoption.md) — adopting the convention + extracting libraries.
|
|
86
|
+
- [ARCHITECTURE.md](ARCHITECTURE.md) — how the gate, the ledger, and the verify rung work.
|
|
87
|
+
- [harness/](harness/README.md) — the behaviour-`verified` testing ground (and bug pipeline).
|
|
88
|
+
- [docs/verification-tiers.md](docs/verification-tiers.md) — why `verified` means *full surface*.
|
|
89
|
+
- [docs/deploying-tep-on-upsun.md](docs/deploying-tep-on-upsun.md) — the catalog site is itself a Spinel-compiled [Tep](https://github.com/OriPekelman/tep) app.
|
|
90
|
+
- [docs/related.md](docs/related.md) — the two unrelated "Spinel" projects, `rv`, and `rubocop_spinel`.
|
|
@@ -21,15 +21,13 @@ module Bundler
|
|
|
21
21
|
def check(lockfile = "Gemfile.lock", strict: false)
|
|
22
22
|
@engine.ensure!
|
|
23
23
|
parsed = Bundler::LockfileParser.new(File.read(lockfile))
|
|
24
|
+
lock_dir = File.dirname(File.expand_path(lockfile))
|
|
24
25
|
rejected = []
|
|
25
26
|
risky = []
|
|
26
27
|
verdicts = []
|
|
27
28
|
|
|
28
29
|
parsed.specs.each do |spec|
|
|
29
|
-
|
|
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)
|
|
30
|
+
v = verdict_for(spec, lock_dir)
|
|
33
31
|
next unless v # skipped (unfetchable / TODO source)
|
|
34
32
|
|
|
35
33
|
verdicts << v
|
|
@@ -43,11 +41,24 @@ module Bundler
|
|
|
43
41
|
|
|
44
42
|
private
|
|
45
43
|
|
|
46
|
-
|
|
44
|
+
# path:/git: sources probe in place (the local checkout); GEM
|
|
45
|
+
# sources go through the cache-backed fetcher. Closes the TODO
|
|
46
|
+
# noted in OriPekelman/spinelgems#3.
|
|
47
|
+
def verdict_for(spec, lock_dir)
|
|
48
|
+
name = spec.name
|
|
49
|
+
version = spec.version.to_s
|
|
47
50
|
cached = @ledger.lookup(name, version, @engine.rev)
|
|
48
51
|
return cached if cached
|
|
49
52
|
|
|
50
|
-
dir =
|
|
53
|
+
dir =
|
|
54
|
+
if spec.source.respond_to?(:path) && spec.source.path
|
|
55
|
+
path = spec.source.path.to_s
|
|
56
|
+
path = File.expand_path(path, lock_dir) unless File.absolute_path?(path)
|
|
57
|
+
File.directory?(path) or raise Error, "path: source for #{name} not found: #{path}"
|
|
58
|
+
path
|
|
59
|
+
else
|
|
60
|
+
@fetcher.fetch(name, version)
|
|
61
|
+
end
|
|
51
62
|
@probe.probe(name, version, dir)
|
|
52
63
|
rescue Error => e
|
|
53
64
|
warn "[spinel-compat] skipped #{name} #{version}: #{e.message}"
|
data/lib/bundler/spinel/cli.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Bundler
|
|
|
6
6
|
# Bundler plugin command. Keeps all logic in the library classes.
|
|
7
7
|
class CLI
|
|
8
8
|
VERDICT_GLYPH = {
|
|
9
|
-
"clean" => "✓", "verified" => "★", "risky" => "~", "rejected" => "✗"
|
|
9
|
+
"clean" => "✓", "loaded" => "○", "verified" => "★", "risky" => "~", "rejected" => "✗"
|
|
10
10
|
}.freeze
|
|
11
11
|
|
|
12
12
|
def initialize(out: $stdout, err: $stderr)
|
|
@@ -18,15 +18,24 @@ module Bundler
|
|
|
18
18
|
cmd = argv.shift
|
|
19
19
|
case cmd
|
|
20
20
|
when "engine" then cmd_engine
|
|
21
|
+
when "install-engine" then cmd_install_engine(argv)
|
|
22
|
+
when "init" then cmd_init(argv)
|
|
21
23
|
when "probe" then cmd_probe(argv)
|
|
22
24
|
when "verify" then cmd_verify(argv)
|
|
23
25
|
when "vendor" then cmd_vendor(argv)
|
|
24
26
|
when "check" then cmd_check(argv)
|
|
25
27
|
when "serve" then cmd_serve(argv)
|
|
26
28
|
when "build-index" then cmd_build_index(argv)
|
|
29
|
+
when "build-site" then cmd_build_site(argv)
|
|
30
|
+
when "build-db" then cmd_build_db(argv)
|
|
31
|
+
when "build-history" then cmd_build_history(argv)
|
|
32
|
+
when "server" then cmd_server(argv)
|
|
27
33
|
when "ledger" then cmd_ledger(argv)
|
|
34
|
+
when "diff" then cmd_diff(argv)
|
|
35
|
+
when "detect-ext" then cmd_detect_ext(argv)
|
|
28
36
|
when "reprobe" then cmd_reprobe(argv)
|
|
29
37
|
when "survey" then cmd_survey(argv)
|
|
38
|
+
when "enrich" then cmd_enrich(argv)
|
|
30
39
|
when nil, "-h", "--help", "help" then usage; 0
|
|
31
40
|
else
|
|
32
41
|
@err.puts "unknown command: #{cmd}"; usage; 2
|
|
@@ -42,9 +51,30 @@ module Bundler
|
|
|
42
51
|
@out.puts "spinel binary : #{e.bin} (#{e.available? ? 'found' : 'MISSING'})"
|
|
43
52
|
@out.puts "engine rev : #{e.rev}"
|
|
44
53
|
@out.puts "ledger : #{Ledger.new.path}"
|
|
54
|
+
unless e.available?
|
|
55
|
+
@out.puts ""
|
|
56
|
+
@out.puts "No engine found. Provision one with: spinel-compat install-engine"
|
|
57
|
+
end
|
|
45
58
|
e.available? ? 0 : 1
|
|
46
59
|
end
|
|
47
60
|
|
|
61
|
+
# Provision the Spinel compiler from source (spinelgems#9). Optional REV
|
|
62
|
+
# arg pins the revision; --force rebuilds even if cached.
|
|
63
|
+
def cmd_install_engine(argv)
|
|
64
|
+
force = !!argv.delete("--force")
|
|
65
|
+
rev = argv.shift # nil → SPINEL_PIN file / default
|
|
66
|
+
EngineInstaller.new(rev: rev, out: @out).install(force: force)
|
|
67
|
+
0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Scaffold a minimal Spinel + Tep project (spinelgems#9 stretch).
|
|
71
|
+
def cmd_init(argv)
|
|
72
|
+
rev = (i = argv.index("--rev")) ? argv[i + 1] : nil
|
|
73
|
+
dir = argv.find { |a| !a.start_with?("-") && a != rev } || "."
|
|
74
|
+
Scaffold.init(dir, out: @out, rev: rev)
|
|
75
|
+
0
|
|
76
|
+
end
|
|
77
|
+
|
|
48
78
|
def cmd_probe(argv)
|
|
49
79
|
dir = (i = argv.index("--dir")) ? argv.delete_at(i + 1).tap { argv.delete_at(i) } : nil
|
|
50
80
|
name = argv.shift or raise Error, "usage: spinel-compat probe NAME [VERSION] [--dir PATH]"
|
|
@@ -64,7 +94,9 @@ module Bundler
|
|
|
64
94
|
def cmd_verify(argv)
|
|
65
95
|
dir = (i = argv.index("--dir")) ? argv.delete_at(i + 1).tap { argv.delete_at(i) } : nil
|
|
66
96
|
smoke = (j = argv.index("--smoke")) ? argv.delete_at(j + 1).tap { argv.delete_at(j) } : nil
|
|
67
|
-
|
|
97
|
+
full = !!argv.delete("--full")
|
|
98
|
+
tests = !!argv.delete("--tests")
|
|
99
|
+
name = argv.shift or raise Error, "usage: spinel-compat verify NAME [VERSION] [--dir PATH] [--smoke FILE | --tests] [--full]"
|
|
68
100
|
engine = Engine.new
|
|
69
101
|
if dir
|
|
70
102
|
version = argv.shift || "path"
|
|
@@ -73,18 +105,43 @@ module Bundler
|
|
|
73
105
|
version = argv.shift || latest_version(name)
|
|
74
106
|
gem_dir = GemFetcher.new.fetch(name, version)
|
|
75
107
|
end
|
|
76
|
-
|
|
108
|
+
# --tests: translate the gem's own minitest/test-unit suite into a
|
|
109
|
+
# Spinel-compilable runner and use it as the smoke (spinelgems#6).
|
|
110
|
+
if tests
|
|
111
|
+
require_relative "test_runner"
|
|
112
|
+
runner = TestRunner.generate_suite(gem_dir)
|
|
113
|
+
raise Error, "no runnable test suite found in #{gem_dir}/test" unless runner
|
|
114
|
+
smoke = File.join(Dir.tmpdir, "__spinel_tests_#{name}.rb")
|
|
115
|
+
File.write(smoke, runner)
|
|
116
|
+
end
|
|
117
|
+
v = Verifier.new(engine, Ledger.new).verify(name, version, gem_dir, smoke: smoke && File.expand_path(smoke), full: full)
|
|
77
118
|
print_verdict(v)
|
|
78
|
-
|
|
119
|
+
File.delete(smoke) if tests && smoke && File.exist?(smoke)
|
|
120
|
+
(v.verified? || v.loaded?) ? 0 : 1
|
|
79
121
|
end
|
|
80
122
|
|
|
81
123
|
def cmd_vendor(argv)
|
|
82
124
|
into = (i = argv.index("--into")) ? argv.delete_at(i + 1).tap { argv.delete_at(i) } : "vendor/spinel"
|
|
125
|
+
# --ext @PLACEHOLDER@=/abs/x.o : reuse a prebuilt .o instead of recompiling (repeatable).
|
|
126
|
+
ext_overrides = {}
|
|
127
|
+
while (e = argv.index("--ext"))
|
|
128
|
+
pair = argv.delete_at(e + 1).to_s
|
|
129
|
+
argv.delete_at(e)
|
|
130
|
+
k, v = pair.split("=", 2)
|
|
131
|
+
ext_overrides[k] = File.expand_path(v) if k && v
|
|
132
|
+
end
|
|
133
|
+
# --no-ext NAME : opt out of an optional C extension (repeatable; also SPINEL_EXT_DISABLE).
|
|
134
|
+
ext_disable = []
|
|
135
|
+
while (d = argv.index("--no-ext"))
|
|
136
|
+
ext_disable << argv.delete_at(d + 1).to_s
|
|
137
|
+
argv.delete_at(d)
|
|
138
|
+
end
|
|
83
139
|
lock = argv.shift || "Gemfile.lock"
|
|
84
140
|
raise Error, "no #{lock}; run `bundle lock` first" unless File.exist?(lock)
|
|
85
141
|
|
|
86
|
-
res = Vendorer.new.vendor(lock, into: into)
|
|
87
|
-
|
|
142
|
+
res = Vendorer.new.vendor(lock, into: into, ext_overrides: ext_overrides, ext_disable: ext_disable)
|
|
143
|
+
ext = res[:extensions].to_i
|
|
144
|
+
@out.puts "vendored #{res[:count]} gem(s)#{ext.positive? ? " (+#{ext} C ext)" : ''} -> #{res[:into]}"
|
|
88
145
|
@out.puts " require_relative \"#{res[:into]}/deps\" from your Spinel entrypoint"
|
|
89
146
|
0
|
|
90
147
|
end
|
|
@@ -128,6 +185,141 @@ module Bundler
|
|
|
128
185
|
0
|
|
129
186
|
end
|
|
130
187
|
|
|
188
|
+
# Fetch rubygems.org metadata (description, downloads, last-update, …) for a
|
|
189
|
+
# gem list (or the ledger at this rev) into a meta.jsonl sidecar.
|
|
190
|
+
def cmd_enrich(argv)
|
|
191
|
+
require_relative "enricher"
|
|
192
|
+
out = (j = argv.index("--out")) ? File.expand_path(argv[j + 1]) : raise(Error, "enrich needs --out FILE")
|
|
193
|
+
jobs = (i = argv.index("--jobs")) ? argv.delete_at(i + 1).to_i.tap { argv.delete_at(i) } : 8
|
|
194
|
+
list = (k = argv.index("--list")) ? argv[k + 1] : nil
|
|
195
|
+
names = if list
|
|
196
|
+
File.readlines(File.expand_path(list)).map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
|
|
197
|
+
else
|
|
198
|
+
rev = Engine.new.rev
|
|
199
|
+
seen = {}
|
|
200
|
+
Ledger.new.each { |v| seen[v.gem] = true if v.rev == rev }
|
|
201
|
+
seen.keys
|
|
202
|
+
end
|
|
203
|
+
Enricher.new(out: out, jobs: jobs).run(names)
|
|
204
|
+
@out.puts "wrote #{out} (#{names.size} gems)"
|
|
205
|
+
0
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Per-gem verdict diff between two revs in the ledger — see what improved
|
|
209
|
+
# or regressed between two surveys. Revs match by prefix (the leading
|
|
210
|
+
# `git:<sha>` is enough); pass --names to list the gems per transition.
|
|
211
|
+
#
|
|
212
|
+
# spinel-compat diff git:2183a92 git:a03bb49 [--names]
|
|
213
|
+
def cmd_diff(argv)
|
|
214
|
+
names_flag = !!argv.delete("--names")
|
|
215
|
+
rev_a = argv.shift or raise Error, "usage: spinel-compat diff REV_A REV_B [--names]"
|
|
216
|
+
rev_b = argv.shift or raise Error, "usage: spinel-compat diff REV_A REV_B [--names]"
|
|
217
|
+
|
|
218
|
+
a = {}
|
|
219
|
+
b = {}
|
|
220
|
+
Ledger.new.each do |v|
|
|
221
|
+
a[v.gem] = v.verdict if v.rev.start_with?(rev_a)
|
|
222
|
+
b[v.gem] = v.verdict if v.rev.start_with?(rev_b)
|
|
223
|
+
end
|
|
224
|
+
raise Error, "no entries match REV_A=#{rev_a}" if a.empty?
|
|
225
|
+
raise Error, "no entries match REV_B=#{rev_b}" if b.empty?
|
|
226
|
+
|
|
227
|
+
both = a.keys & b.keys
|
|
228
|
+
unchanged = 0
|
|
229
|
+
transitions = Hash.new { |h, k| h[k] = [] }
|
|
230
|
+
both.each do |g|
|
|
231
|
+
if a[g] == b[g] then unchanged += 1
|
|
232
|
+
else transitions["#{a[g]} -> #{b[g]}"] << g
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
@out.puts "rev A: #{rev_a} (#{a.size} gems)"
|
|
237
|
+
@out.puts "rev B: #{rev_b} (#{b.size} gems)"
|
|
238
|
+
@out.puts "common: #{both.size} · unchanged: #{unchanged} · changed: #{both.size - unchanged}"
|
|
239
|
+
@out.puts "only in A: #{(a.keys - b.keys).size} · only in B: #{(b.keys - a.keys).size}"
|
|
240
|
+
@out.puts
|
|
241
|
+
transitions.sort_by { |_, gs| -gs.size }.each do |t, gs|
|
|
242
|
+
@out.printf("%-26s %d\n", t, gs.size)
|
|
243
|
+
gs.sort.each { |g| @out.puts " #{g}" } if names_flag
|
|
244
|
+
end
|
|
245
|
+
0
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Infer a draft spinel-ext.json from a gem's `ffi_cflags "@PLACEHOLDER@"`
|
|
249
|
+
# declarations + nearby `.c` files. Keeps the C-ext convention strictly
|
|
250
|
+
# consumer-side: gem authors don't have to ship the manifest.
|
|
251
|
+
def cmd_detect_ext(argv)
|
|
252
|
+
require_relative "ext_detector"
|
|
253
|
+
out_file = (j = argv.index("--out")) ? argv.delete_at(j + 1).tap { argv.delete_at(j) } : nil
|
|
254
|
+
dir = argv.shift or raise Error, "usage: spinel-compat detect-ext GEM_DIR [--out FILE]"
|
|
255
|
+
|
|
256
|
+
json = ExtDetector.new(File.expand_path(dir)).to_json
|
|
257
|
+
if out_file
|
|
258
|
+
File.write(File.expand_path(out_file), json + "\n")
|
|
259
|
+
@out.puts "wrote #{out_file}"
|
|
260
|
+
else
|
|
261
|
+
@out.puts json
|
|
262
|
+
end
|
|
263
|
+
0
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Build the spinelgems.org static deploy tree: presentation + ledger-driven
|
|
267
|
+
# catalog, plus the Compact Index (apex double-duty) when a --store is given.
|
|
268
|
+
def cmd_build_site(argv)
|
|
269
|
+
require_relative "site"
|
|
270
|
+
out = (j = argv.index("--out")) ? argv[j + 1] : raise(Error, "build-site needs --out DIR")
|
|
271
|
+
store = (i = argv.index("--store")) ? File.expand_path(argv[i + 1]) : nil
|
|
272
|
+
min = (k = argv.index("--min")) ? argv[k + 1].to_sym : :verified
|
|
273
|
+
# Default to the committed snapshot — survey-193k/ holds compat.jsonl
|
|
274
|
+
# (the ledger backing the deploy) and meta.jsonl (the PG-dump-derived
|
|
275
|
+
# full per-gem metadata, 193k entries). `survey-out/` is a working
|
|
276
|
+
# directory for per-run probes; not what the public catalog renders from.
|
|
277
|
+
ledger_path = (l = argv.index("--ledger")) ? argv[l + 1] : "survey-193k/compat.jsonl"
|
|
278
|
+
meta_path = (m = argv.index("--meta")) ? argv[m + 1] : "survey-193k/meta.jsonl"
|
|
279
|
+
site = Site.new(ledger: Ledger.new(path: File.expand_path(ledger_path)),
|
|
280
|
+
meta_path: File.expand_path(meta_path))
|
|
281
|
+
dir = site.build(File.expand_path(out), store: store, min_verdict: min)
|
|
282
|
+
@out.puts "built spinelgems.org site -> #{dir}" \
|
|
283
|
+
"#{store ? " (+ Compact Index from #{store})" : ' (presentation + catalog; pass --store DIR to add the Compact Index)'}"
|
|
284
|
+
0
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Materialize the catalog into a read-only SQLite DB for the dynamic (Tep)
|
|
288
|
+
# server. Same ledger/meta defaults as build-site; reuses Site#rows.
|
|
289
|
+
def cmd_build_db(argv)
|
|
290
|
+
require_relative "site"
|
|
291
|
+
out = (j = argv.index("--out")) ? argv[j + 1] : raise(Error, "build-db needs --out DB")
|
|
292
|
+
ledger_path = (l = argv.index("--ledger")) ? argv[l + 1] : "survey-193k/compat.jsonl"
|
|
293
|
+
meta_path = (m = argv.index("--meta")) ? argv[m + 1] : "survey-193k/meta.jsonl"
|
|
294
|
+
site = Site.new(ledger: Ledger.new(path: File.expand_path(ledger_path)),
|
|
295
|
+
meta_path: File.expand_path(meta_path))
|
|
296
|
+
db = site.build_db(File.expand_path(out))
|
|
297
|
+
@out.puts "built catalog DB -> #{db}"
|
|
298
|
+
0
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Render the historical-record page (verdict-mix timeline + deltas across
|
|
302
|
+
# the per-rev corpus snapshots).
|
|
303
|
+
def cmd_build_history(argv)
|
|
304
|
+
require_relative "history"
|
|
305
|
+
out = (j = argv.index("--out")) ? argv[j + 1] : raise(Error, "build-history needs --out FILE")
|
|
306
|
+
f = History.new(Dir.pwd).build_html(File.expand_path(out))
|
|
307
|
+
@out.puts "built history -> #{f}"
|
|
308
|
+
0
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Serve the static site + (with --store) the Compact Index from one process
|
|
312
|
+
# — what the deploy host runs. Port defaults to $PORT (Upsun) then 9292.
|
|
313
|
+
def cmd_server(argv)
|
|
314
|
+
require_relative "server"
|
|
315
|
+
pub = (i = argv.index("--public")) ? argv[i + 1] : "public"
|
|
316
|
+
port = (j = argv.index("--port")) ? argv[j + 1].to_i : Integer(ENV.fetch("PORT", "9292"))
|
|
317
|
+
store = (k = argv.index("--store")) ? File.expand_path(argv[k + 1]) : nil
|
|
318
|
+
min = (m = argv.index("--min")) ? argv[m + 1].to_sym : :verified
|
|
319
|
+
Server.new(public_dir: File.expand_path(pub), store: store, min_verdict: min).run(port: port)
|
|
320
|
+
0
|
|
321
|
+
end
|
|
322
|
+
|
|
131
323
|
def cmd_ledger(argv)
|
|
132
324
|
rev = (i = argv.index("--rev")) ? argv[i + 1] : nil
|
|
133
325
|
Ledger.new.each do |v|
|
|
@@ -166,19 +358,27 @@ module Bundler
|
|
|
166
358
|
jobs = (i = argv.index("--jobs")) ? argv.delete_at(i + 1).to_i.tap { argv.delete_at(i) } : 4
|
|
167
359
|
out_file = (j = argv.index("--out")) ? argv.delete_at(j + 1).tap { argv.delete_at(j) } : nil
|
|
168
360
|
list = (k = argv.index("--list")) ? argv.delete_at(k + 1).tap { argv.delete_at(k) } : nil
|
|
361
|
+
# --refresh: ignore cache hits in the ledger and re-probe every gem.
|
|
362
|
+
# Default (no flag) reuses any existing verdict at the current engine rev
|
|
363
|
+
# — so pointing SPINEL_COMPAT_LEDGER at the canonical ledger gives a
|
|
364
|
+
# cross-run incremental survey (only new gems get the compile cost).
|
|
365
|
+
refresh = !!argv.delete("--refresh")
|
|
169
366
|
names = if list
|
|
170
367
|
File.readlines(File.expand_path(list)).map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
|
|
171
368
|
else
|
|
172
369
|
argv.dup
|
|
173
370
|
end
|
|
174
|
-
raise Error, "usage: spinel-compat survey GEM... | --list FILE [--jobs N] [--out report.md]" if names.empty?
|
|
371
|
+
raise Error, "usage: spinel-compat survey GEM... | --list FILE [--jobs N] [--out report.md] [--refresh]" if names.empty?
|
|
175
372
|
|
|
176
|
-
survey = Survey.new(jobs: jobs)
|
|
373
|
+
survey = Survey.new(jobs: jobs, refresh: refresh)
|
|
177
374
|
survey.run(names)
|
|
178
375
|
report = survey.report(names)
|
|
179
376
|
if out_file
|
|
180
|
-
File.
|
|
181
|
-
|
|
377
|
+
out_path = File.expand_path(out_file)
|
|
378
|
+
File.write(out_path, report)
|
|
379
|
+
tsv_path = File.join(File.dirname(out_path), "candidates.tsv")
|
|
380
|
+
File.write(tsv_path, survey.candidates_tsv(names))
|
|
381
|
+
@out.puts "wrote #{out_file} + candidates.tsv (#{names.size} gems)"
|
|
182
382
|
else
|
|
183
383
|
@out.puts report
|
|
184
384
|
end
|
|
@@ -207,6 +407,8 @@ module Bundler
|
|
|
207
407
|
spinel-compat — Spinel gem-compatibility ledger
|
|
208
408
|
|
|
209
409
|
spinel-compat engine show detected compiler + engine rev
|
|
410
|
+
spinel-compat install-engine [REV] fetch+build the Spinel compiler -> ~/.cache/spinel
|
|
411
|
+
spinel-compat init [DIR] scaffold a Spinel+Tep project (Gemfile, app.rb, bin/build)
|
|
210
412
|
spinel-compat probe NAME [VERSION] probe one gem, record a verdict
|
|
211
413
|
spinel-compat verify NAME [--smoke F] differential CRuby-vs-Spinel run -> verified
|
|
212
414
|
spinel-compat vendor [LOCK] [--into D] place deps where Spinel finds them + deps.rb
|
|
@@ -214,7 +416,11 @@ module Bundler
|
|
|
214
416
|
spinel-compat survey GEM... | --list F wholesale review -> reason histogram
|
|
215
417
|
spinel-compat serve --store DIR curated source (only vetted gems)
|
|
216
418
|
spinel-compat build-index --store DIR --out DIR static curated index
|
|
419
|
+
spinel-compat build-site --out DIR [--store DIR] static site (presentation + catalog [+ index])
|
|
420
|
+
spinel-compat server --public DIR [--store DIR] serve site + Compact Index (one process; $PORT)
|
|
217
421
|
spinel-compat ledger [--rev REV] dump recorded verdicts
|
|
422
|
+
spinel-compat diff REV_A REV_B [--names] per-gem verdict changes between two revs
|
|
423
|
+
spinel-compat detect-ext GEM_DIR [--out F] draft spinel-ext.json from a gem's ffi_cflags markers
|
|
218
424
|
spinel-compat reprobe re-probe known gems under current rev
|
|
219
425
|
|
|
220
426
|
Verdicts: ✓ clean ★ verified ~ risky ✗ rejected
|
|
@@ -18,15 +18,25 @@ module Bundler
|
|
|
18
18
|
class Engine
|
|
19
19
|
attr_reader :dir, :bin
|
|
20
20
|
|
|
21
|
-
def initialize(dir:
|
|
22
|
-
|
|
21
|
+
def initialize(dir: nil)
|
|
22
|
+
# Resolution order: explicit SPINEL_DIR, then an engine provisioned by
|
|
23
|
+
# `spinel-compat install-engine` (~/.cache/spinel/current), then ~/spinel.
|
|
24
|
+
@dir = dir || ENV["SPINEL_DIR"] || default_dir
|
|
23
25
|
# Prefer a checkout at `dir` (gives a git rev); otherwise fall back to a
|
|
24
26
|
# `spinel` on PATH (installed binary — rev becomes a binary hash). This
|
|
25
27
|
# makes the default work for most setups without configuration.
|
|
26
|
-
local = File.join(dir, "spinel")
|
|
28
|
+
local = File.join(@dir, "spinel")
|
|
27
29
|
@bin = File.executable?(local) ? local : (which("spinel") || local)
|
|
28
30
|
end
|
|
29
31
|
|
|
32
|
+
# The provisioned-engine cache (install-engine) if it has a built binary,
|
|
33
|
+
# else the conventional ~/spinel. Keeps zero-config working after a
|
|
34
|
+
# `spinel-compat install-engine`.
|
|
35
|
+
def default_dir
|
|
36
|
+
cur = File.expand_path("~/.cache/spinel/current")
|
|
37
|
+
File.executable?(File.join(cur, "spinel")) ? cur : File.expand_path("~/spinel")
|
|
38
|
+
end
|
|
39
|
+
|
|
30
40
|
def available?
|
|
31
41
|
File.executable?(@bin)
|
|
32
42
|
end
|
|
@@ -76,7 +86,10 @@ module Bundler
|
|
|
76
86
|
# Not a git checkout: hash the binary so distinct builds get distinct keys.
|
|
77
87
|
return "missing" unless available?
|
|
78
88
|
|
|
79
|
-
|
|
89
|
+
# Fully-qualified: inside `module Bundler`, a bare `Digest` resolves
|
|
90
|
+
# to Bundler's own `Bundler::Digest` (no SHA256), so probe/check
|
|
91
|
+
# NameError'd. Reach the stdlib digest explicitly.
|
|
92
|
+
"bin:#{::Digest::SHA256.file(@bin).hexdigest[0, 12]}"
|
|
80
93
|
end
|
|
81
94
|
|
|
82
95
|
def which(cmd)
|