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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fedb059cdc7900cd98ccfff408a28e1fb4e4eaad68d9e7afe48c9d967cb97b71
4
- data.tar.gz: a1d6fbfb31256d58cacab1cc120290455bd38de869295504a1c6dcdee96ea8da
3
+ metadata.gz: 99f01821538cfb0ce86a06e2ecb1251f89e1086530c59423715f509d18e0c717
4
+ data.tar.gz: ca1c456f3bf3c4d9e4424a0466daa23bb4495b64357adfed8a69145953d54a06
5
5
  SHA512:
6
- metadata.gz: acd75d23500e6af2d3e55c116bc0f5d82e8a4c8b1912049a349967e9cc920134ffe4820862abeeb6a753445f0a668cf26a9248c6aed4b983ea0dff7c262c13ee
7
- data.tar.gz: 1bd2fa39872c215534c919f6a8c05cf01cf6dc30e5cce162d470d8d0ec3d750a09e06bc3a404d05f1ac4195a0c86232ca6e93f9d761e5d99771603361fd58756
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
- | `verified` | `clean` **and** the gem's own tests pass through a Spinel-compiled harness | curated whitelist + platform badge |
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.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).
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: (replaces rsync)
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` (exit 18 under CRuby). Nothing here is novel — that's the point.
45
+ `bundle install`. Nothing here is novel — that's the point.
35
46
 
36
47
  ## Quick start
37
48
 
38
49
  ```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
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
- As a Bundler plugin:
56
+ Or add the gate to an existing project:
49
57
 
50
58
  ```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
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
- ## 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.
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
- # 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)
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
- def verdict_for(name, version)
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 = @fetcher.fetch(name, version)
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}"
@@ -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
- name = argv.shift or raise Error, "usage: spinel-compat verify NAME [VERSION] [--dir PATH] [--smoke FILE]"
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
- v = Verifier.new(engine, Ledger.new).verify(name, version, gem_dir, smoke: smoke && File.expand_path(smoke))
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
- v.verified? ? 0 : 1
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
- @out.puts "vendored #{res[:count]} gem(s) -> #{res[:into]}"
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.write(File.expand_path(out_file), report)
181
- @out.puts "wrote #{out_file} (#{names.size} gems)"
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: ENV.fetch("SPINEL_DIR", File.expand_path("~/spinel")))
22
- @dir = dir
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
- "bin:#{Digest::SHA256.file(@bin).hexdigest[0, 12]}"
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)