enola 0.0.0 → 0.4.4.1

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: 9fd2d396d3b108ec57cede0d8362e11fef4a6710352bc83b6336d205eace47aa
4
- data.tar.gz: b3f11c1c771237daf33b2e45af86f14cdb98b4756a330e60dccfae2fc7106a39
3
+ metadata.gz: 84ee668ecfaed2e6d8ebc55fe5054e1635300502f762887f2d7b96660beb908f
4
+ data.tar.gz: 2905c89cad26afb7f6226515c7b3620ed6e089c16ff5886398f4043deda50360
5
5
  SHA512:
6
- metadata.gz: 73dd9a25719c7206386bf842b6e871c1ea05793b045d7899af11abc85883001225e79f1f7b2d53414d9ec10676136d3c09e7ae42d55a811f0be4e457a36fdd66
7
- data.tar.gz: b3787673babd9399a3c625ed723a1cdaf7ead0d1e819fe88faeed346bba8c87163e6201f1ce4ad3514baa0f6791f6bb55eb3be684be0cdf8781a8fc8340b7f60
6
+ metadata.gz: e8b8a19a54c9f13be8b2dd6a1d62b5cb999d14a7ff959f0771ca25cfc63b6fac3822bc31ccd676cc44c60e4106401c626f9fc1e7b2802a68d3f5f68a0c23c322
7
+ data.tar.gz: 42a9119e1b7d33d1a20b3e3d9f7a79f2c8199de3599365f6d13674db19a968aba0a749180014b061bb413940a17ae8212ebf63181ee40302acdcae20952c1bb5
data/CHANGELOG.md ADDED
@@ -0,0 +1,78 @@
1
+ ## munola 0.4.4.2 and munola-rb 0.4.4.2 (2026-08-23)
2
+
3
+ munola follows the channel it drives. Its binary is now cut from the stable
4
+ channel after it took eight changes measured against a Ruby architecture
5
+ linter, so the gem's version moves with the release it fetches. The Rails
6
+ layer is its own gem, `munola-rb`, the way `enola-rb` is for the upstream
7
+ channel; `require "munola"` loads no Rails. A catalogue recipe the binary
8
+ already binds is no longer bound a second time.
9
+
10
+ ## enola 0.4.4.1 and enola-rb 0.4.4.1 (2026-08-23)
11
+
12
+ Wrapper fixes on the same upstream release. A test that set the channel put
13
+ back upstream rather than what it found, so on some orderings a channel gem's
14
+ own installation read as upstream. The Rails layer packaged its generator
15
+ through a glob wide enough to take another channel's; it now packages its own.
16
+ Both gems still drive upstream v0.4.4.
17
+
18
+ ## munola-rb 0.4.4.1 (2026-08-23)
19
+
20
+ The Rails layer over munola, what enola-rb is to enola: `rails generate
21
+ munola:install [--tenant-column COLUMN]` writes the starter laws, the
22
+ bindings the binary's own init picks and the munola catalogue, and the
23
+ enola:init, enola:snapshot and enola:check tasks drive munola's binary
24
+ because munola installs its own resolver. The generator and the railtie
25
+ move here out of the munola gem, so `require "munola"` loads no Rails and a
26
+ project that only drives the binary carries no Rails machinery. The recipe
27
+ catalogue, the detection and the tenant-column argument stay munola's.
28
+
29
+ `Enola.resolver_factory` is readable as well as writable, so whoever
30
+ replaces it can put back what was installed rather than what it assumed.
31
+
32
+ ## munola 0.4.4.1 (2026-08-23)
33
+
34
+ The wrapper over the munola channel, first release with an implementation.
35
+ `munola init` writes the starter laws and upstream's bindings the way
36
+ `enola:install` does, then the recipe catalogue from enola-guides, binds the
37
+ recipes the tree shows a need for, fills the tenant foreign-key template
38
+ from the schema, writes `mcp-arch.yaml` with Prism and Rubydex on and fetches
39
+ the Rubydex library; `munola --version` names the munola version, its
40
+ upstream base and the binary that answered; `MUNOLA_BINARY` names a binary
41
+ to drive until the first munola release is cut. The channel's tag shape is
42
+ `munola-v<version>` on the fork's releases.
43
+
44
+ ## enola 0.4.4 (2026-08-23)
45
+
46
+ The wrapper, first release with an implementation: a pure-Ruby `enola`
47
+ executable that drives upstream's v0.4.4. The binary is fetched on first use
48
+ from the release for the running platform, verified against the sha256 the
49
+ release publishes, and kept under `~/.cache/enola/upstream/0.4.4/`; a binary on
50
+ PATH is used only when it answers the pinned version; offline with an empty
51
+ cache is a refusal that names the cache and the release. Every argument and
52
+ exit code is forwarded; `--verbose` adds one stderr line naming channel,
53
+ version and where the binary came from; `enola --wrapper-probe` prints the
54
+ channel, pin, found version and which surfaces the binary answers.
55
+
56
+ Both providers by default: the gem vendors upstream's Prism provider script
57
+ at the pinned version and depends on the `prism` gem; `enola init` writes
58
+ `mcp-arch.yaml` with Prism (run by this Ruby) and Rubydex on, then runs the
59
+ binary's `constraints init`; the Rubydex engine library is fetched once per
60
+ binary version after the binary itself, a failure printed once as a named
61
+ skip. `Enola::Config.write_default` and `Enola::Providers` are the seams the
62
+ Rails layer and munola call.
63
+
64
+ ## enola-rb 0.4.4 (2026-08-23)
65
+
66
+ The Rails layer, first release with an implementation. `rails generate
67
+ enola:install` writes `enola/constraints/` from the enola-guides starter laws,
68
+ binds the recipes the binary's own `constraints init` picks, writes the rest
69
+ as commented bindings and ignores `.enola/`; the `enola:init`,
70
+ `enola:snapshot` and `enola:check` rake tasks drive the binary the `enola` gem
71
+ fetches. A surface the pinned binary lacks is refused by name with the remedy;
72
+ an absent binary still leaves the laws written. Depends on `enola` at the same
73
+ version and on `enola-guides`. The generator writes the provider config first,
74
+ so a fresh app's graph carries Prism and Rubydex facts from its first snapshot.
75
+
76
+ ## munola 0.0.0
77
+
78
+ Placeholder. `munola` is the same wrapper over another channel. Follows.
data/README.md CHANGED
@@ -1,13 +1,128 @@
1
1
  # enola-rb
2
2
 
3
- Runs [enola](https://github.com/enola-labs/enola), the architecture-graph
4
- tool, from Ruby, with presets and defaults chosen for Rails stacks.
3
+ Four pure-Ruby gems, one repository, for running
4
+ [enola](https://github.com/enola-labs/enola), the architecture-graph tool,
5
+ from Ruby. None of them carries a binary or compiles anything.
5
6
 
6
- Two channels, picked in your Gemfile. `enola-rb` on its own drives whichever
7
- enola binary is on your path. Adding the `munola` gem switches it to the build
8
- this project ships, which carries capabilities not yet in an upstream release.
7
+ Two channels, the same layering on both: `enola` wraps the released upstream
8
+ binary and `enola-rb` adds the Rails layer on it; `munola` wraps the munola
9
+ binary and `munola-rb` adds the Rails layer on that. The Rails code is written
10
+ once, in `enola-rb`, and the munola gems add only what is munola's own.
9
11
 
10
- Presets come from
11
- [enola-guides](https://github.com/misabegovic/enola-guides).
12
+ ## `enola`
12
13
 
13
- Placeholder release. No implementation yet.
14
+ The wrapper of the released enola. Its version is the upstream release it
15
+ drives: `enola 0.4.4` runs enola-labs' v0.4.4.
16
+
17
+ ```ruby
18
+ gem "enola"
19
+ ```
20
+
21
+ ```sh
22
+ bundle exec enola --generate .
23
+ bundle exec enola baseline pin .
24
+ bundle exec enola check .
25
+ ```
26
+
27
+ The first command that needs the binary downloads the release for your
28
+ platform (linux, darwin, windows; amd64, arm64), verifies it against the
29
+ sha256 file the same release publishes, and keeps it under
30
+ `~/.cache/enola/upstream/0.4.4/` (`ENOLA_CACHE_DIR` moves the root). An
31
+ `enola` already on your PATH is used only when it answers the pinned version;
32
+ offline with an empty cache is a refusal that names the cache and the release
33
+ rather than a fallback to whatever is installed. Nothing downloads at
34
+ `bundle install`.
35
+
36
+ Every argument and exit code is forwarded unchanged; the wrapper adds no flag
37
+ of its own to enola's surface. `--verbose` on any command writes one line to
38
+ stderr first, naming the channel, the version and where the binary came from.
39
+ `enola --wrapper-probe` prints the channel, the pin, the version the binary
40
+ answers and which surfaces it has (`constraints`, `providers`, `check`,
41
+ `hook`), read by running them, never by comparing version strings.
42
+
43
+ This gem is not an enola-labs release; it runs theirs.
44
+
45
+ ## `enola-rb`
46
+
47
+ The Rails layer over `enola`. It depends on `enola` at the same version and on
48
+ [enola-guides](https://github.com/misabegovic/enola-guides), and adds nothing
49
+ to the binary's surface.
50
+
51
+ ```ruby
52
+ gem "enola-rb"
53
+ ```
54
+
55
+ ```sh
56
+ bin/rails generate enola:install
57
+ bin/rake enola:snapshot
58
+ bin/rake enola:check
59
+ ```
60
+
61
+ The generator writes `enola/constraints/` from the guides' starter laws (four
62
+ laws a Rails team keeps, each with its reason), asks the binary's own
63
+ `constraints init` to bind the shipped recipes whose roles resolve in the app,
64
+ writes every other shipped recipe as a commented binding to uncomment once the
65
+ directories exist, and ignores `.enola/`. It reads what `init` wrote rather
66
+ than its exit code, and when the binary cannot be fetched it still writes the
67
+ laws and says so. `enola:snapshot` generates and pins the baseline;
68
+ `enola:check` grades the working tree against it and fails on a new breach of
69
+ a declared law. A surface the pinned binary lacks is refused by name with the
70
+ remedy.
71
+
72
+ ## `munola`
73
+
74
+ One person's taste on top of enola: the same wrapper over another channel,
75
+ the builds cut from [a fork of enola](https://github.com/misabegovic/enola),
76
+ versioned as the upstream they are built on plus a fourth segment
77
+ (`0.4.4.1` drives a build on v0.4.4), each release naming what differs from
78
+ that upstream. It is offered upstream where it fits and is not positioned
79
+ against it. What it adds is the channel and the catalogue.
80
+
81
+ ```ruby
82
+ gem "munola"
83
+ ```
84
+
85
+ ```sh
86
+ bundle exec munola init . --tenant-column company_id
87
+ bundle exec munola --version
88
+ ```
89
+
90
+ `munola init` does what `enola:install` does, then what only munola carries:
91
+ it writes the recipe catalogue the `enola-guides` gem ships (Ember
92
+ conventions, data ownership, API boundaries, background work, a tenant
93
+ foreign key) into `enola/recipes/`, binds the recipes the tree justifies
94
+ (`ember-cli-build.js`, a schema with `app/models`, `config/routes.rb` with
95
+ `app/policies`, `app/tasks`, a column most tables share confirmed against
96
+ `db/schema.rb`) and writes every other one as a commented binding; it fills
97
+ the tenant template from the schema's own table names, never from an
98
+ inflection table; it writes `mcp-arch.yaml` with both Ruby providers on by
99
+ default, Prism through the script the `enola` gem carries and Rubydex built
100
+ into the binary, and fetches the Rubydex library, a failed fetch reported as a
101
+ named skip. It never asks a question. `munola --version` names the munola
102
+ version, the upstream it is built on and which binary answered.
103
+
104
+ The binary comes from the fork's releases the way `enola`'s comes from
105
+ upstream's, fetched on first use and verified against the sha256 the release
106
+ publishes; `MUNOLA_BINARY=/path/to/enola` names one to drive instead, and
107
+ every command says which answered. `munola` depends on `enola` at its upstream
108
+ version and on `enola-guides` 0.3.1 or later; requiring it loads no Rails.
109
+
110
+ ## `munola-rb`
111
+
112
+ The Rails layer over `munola`, what `enola-rb` is to `enola`. It depends on
113
+ both at their own versions and adds one generator.
114
+
115
+ ```ruby
116
+ gem "munola-rb"
117
+ ```
118
+
119
+ ```sh
120
+ bin/rails generate munola:install --tenant-column company_id
121
+ bin/rake enola:snapshot
122
+ bin/rake enola:check
123
+ ```
124
+
125
+ The generator runs the same install `munola init` does. The rake tasks are
126
+ enola-rb's and need no munola copy: `munola` installs its own resolver when it
127
+ loads, so `enola:snapshot` and `enola:check` drive the munola binary in an app
128
+ that has this gem, and the upstream binary in one that does not.
data/exe/enola ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "enola"
5
+
6
+ exit Enola::CLI.start(ARGV)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enola
4
+ Channel = Struct.new(:name, :release_base, :version, :asset_prefix, :tag_prefix, keyword_init: true) do
5
+ def asset(platform)
6
+ "#{asset_prefix}-#{version}-#{platform}.tar.gz"
7
+ end
8
+
9
+ def checksum_asset(platform)
10
+ "#{asset_prefix}-#{version}-#{platform}.sha256"
11
+ end
12
+
13
+ def tag
14
+ "#{tag_prefix || 'v'}#{version}"
15
+ end
16
+
17
+ def url(file)
18
+ "#{release_base.chomp('/')}/#{tag}/#{file}"
19
+ end
20
+
21
+ def cache_dir(root)
22
+ File.join(root, name, version)
23
+ end
24
+
25
+ def to_s
26
+ "#{name} #{version}"
27
+ end
28
+ end
29
+
30
+ Channel::UPSTREAM = Channel.new(
31
+ name: "upstream",
32
+ release_base: "https://github.com/enola-labs/enola/releases/download",
33
+ version: UPSTREAM_VERSION,
34
+ asset_prefix: "enola"
35
+ ).freeze
36
+ end
data/lib/enola/cli.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Enola
6
+ module CLI
7
+ def self.start(argv, stderr: $stderr, stdout: $stdout)
8
+ case argv.first
9
+ when "--wrapper-probe"
10
+ found = Enola.resolver.resolve
11
+ stdout.puts JSON.pretty_generate(Probe.new(found.path).report.merge(source: found.source))
12
+ 0
13
+ when "--wrapper-fetch"
14
+ stdout.puts Fetcher.new.fetch
15
+ 0
16
+ when "init"
17
+ root = argv[1] || "."
18
+ written = Config.write_default(root)
19
+ stdout.puts(written ? "wrote #{written}" : "#{Config.path(root)} already exists, left as is")
20
+ Runner.new(stderr: stderr).run(["constraints", "init", root])
21
+ else
22
+ Runner.new.exec(argv)
23
+ end
24
+ rescue Error => e
25
+ stderr.puts "enola: #{e.message}"
26
+ 127
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enola
4
+ module Config
5
+ FILE = "mcp-arch.yaml"
6
+
7
+ IGNORE = %w[**/.git/** **/node_modules/** **/vendor/** **/tmp/** **/log/** **/coverage/** **/public/assets/**].freeze
8
+
9
+ def self.path(root)
10
+ File.join(File.expand_path(root), FILE)
11
+ end
12
+
13
+ def self.write_default(root, ruby: Providers.ruby)
14
+ target = path(root)
15
+ return nil if File.exist?(target)
16
+
17
+ File.write(target, render(ruby: ruby))
18
+ target
19
+ end
20
+
21
+ def self.render(ruby: Providers.ruby)
22
+ <<~YAML
23
+ # Written by the enola gem; edit freely. Both providers are on by default:
24
+ # Prism runs under the Ruby that runs the gem, Rubydex is built into the
25
+ # binary and fetched once. Remove one to leave it out of the graph.
26
+ repo: .
27
+ repos:
28
+ - .
29
+ ignore:
30
+ #{IGNORE.map { |glob| " - \"#{glob}\"" }.join("\n")}
31
+ providers:
32
+ - name: prism
33
+ command: [#{ruby.inspect}, #{Providers.prism_script.inspect}]
34
+ expected_version: "#{Providers.prism_version || '0.1.0'}"
35
+ - name: rubydex
36
+ expected_version: "#{Providers::RUBYDEX_EXPECTED_VERSION}"
37
+ YAML
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enola
4
+ class Error < StandardError; end
5
+
6
+ class Unavailable < Error; end
7
+
8
+ class Unverified < Error; end
9
+
10
+ class UnsupportedPlatform < Error; end
11
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "net/http"
6
+ require "open3"
7
+ require "tmpdir"
8
+ require "uri"
9
+
10
+ module Enola
11
+ class Fetcher
12
+ REDIRECTS = 5
13
+
14
+ def initialize(channel: Enola.channel, cache_root: Enola.cache_root, platform: Platform.current)
15
+ @channel = channel
16
+ @cache_root = cache_root
17
+ @platform = platform
18
+ end
19
+
20
+ def binary_path
21
+ File.join(@channel.cache_dir(@cache_root), Platform.windows?(@platform) ? "enola.exe" : "enola")
22
+ end
23
+
24
+ def cached?
25
+ File.executable?(binary_path)
26
+ end
27
+
28
+ def fetch
29
+ return binary_path if cached?
30
+
31
+ Dir.mktmpdir("enola-fetch") do |work|
32
+ asset = @channel.asset(@platform)
33
+ tarball = File.join(work, asset)
34
+ write(@channel.url(asset), tarball)
35
+ verify(tarball, read(@channel.url(@channel.checksum_asset(@platform))), asset)
36
+ unpack(tarball, work)
37
+ end
38
+ binary_path
39
+ end
40
+
41
+ private
42
+
43
+ def verify(tarball, checksum_text, asset)
44
+ expected = checksum_text.split.first.to_s.downcase
45
+ actual = Digest::SHA256.file(tarball).hexdigest
46
+ return if !expected.empty? && expected == actual
47
+
48
+ raise Unverified, "#{asset} did not match the checksum #{@channel.name} published for #{@channel.version} " \
49
+ "(expected #{expected.empty? ? 'nothing readable' : expected[0, 12]}, got #{actual[0, 12]}); nothing was installed"
50
+ end
51
+
52
+ def unpack(tarball, work)
53
+ unpacked = File.join(work, "unpacked")
54
+ FileUtils.mkdir_p(unpacked)
55
+ _, err, status = Open3.capture3("tar", "xzf", tarball, "-C", unpacked)
56
+ raise Unavailable, "could not unpack #{File.basename(tarball)}: #{err.strip}" unless status.success?
57
+
58
+ inner = Dir.children(unpacked).find { |name| name.start_with?("#{@channel.asset_prefix}-") && !name.end_with?(".tar.gz") }
59
+ raise Unavailable, "#{File.basename(tarball)} holds no #{@channel.asset_prefix} binary" unless inner
60
+
61
+ dir = @channel.cache_dir(@cache_root)
62
+ begin
63
+ FileUtils.mkdir_p(dir)
64
+ rescue SystemCallError => e
65
+ raise Unavailable, "cannot write the cache at #{dir} (#{e.message}); set ENOLA_CACHE_DIR to a writable directory"
66
+ end
67
+ FileUtils.mv(File.join(unpacked, inner), binary_path, force: true)
68
+ FileUtils.chmod(0o755, binary_path)
69
+ %w[LICENSE NOTICE].each do |extra|
70
+ source = File.join(unpacked, extra)
71
+ FileUtils.mv(source, File.join(dir, extra), force: true) if File.exist?(source)
72
+ end
73
+ end
74
+
75
+ def read(url)
76
+ body = +""
77
+ stream(url) { |chunk| body << chunk }
78
+ body
79
+ end
80
+
81
+ def write(url, path)
82
+ File.open(path, "wb") { |file| stream(url) { |chunk| file.write(chunk) } }
83
+ end
84
+
85
+ def stream(url, hops = 0, &block)
86
+ uri = URI(url)
87
+ return File.open(uri.path, "rb") { |file| file.each(nil, 1 << 16, &block) } if uri.scheme == "file"
88
+
89
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 15, read_timeout: 60) do |http|
90
+ http.request(Net::HTTP::Get.new(uri)) do |response|
91
+ case response
92
+ when Net::HTTPRedirection
93
+ raise Unavailable, "too many redirects fetching #{url}" if hops >= REDIRECTS
94
+
95
+ return stream(response["location"], hops + 1, &block)
96
+ when Net::HTTPSuccess
97
+ response.read_body(&block)
98
+ else
99
+ raise Unavailable, "#{url} answered #{response.code}"
100
+ end
101
+ end
102
+ end
103
+ rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, IOError => e
104
+ raise Unavailable, offline_message(e)
105
+ end
106
+
107
+ def offline_message(error)
108
+ "enola #{@channel} is not in the cache at #{@channel.cache_dir(@cache_root)} and could not be fetched " \
109
+ "from #{@channel.release_base} (#{error.class}: #{error.message}); connect once, or place the verified binary there"
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Enola
6
+ module Platform
7
+ OS = { "linux" => "linux", "darwin" => "darwin", "mingw" => "windows", "mswin" => "windows" }.freeze
8
+ ARCH = { "x86_64" => "amd64", "amd64" => "amd64", "aarch64" => "arm64", "arm64" => "arm64" }.freeze
9
+
10
+ def self.current(config = RbConfig::CONFIG)
11
+ os = OS.find { |key, _| config["host_os"].include?(key) }&.last
12
+ arch = ARCH[config["host_cpu"]]
13
+ raise UnsupportedPlatform, "no enola release for #{config['host_os']} on #{config['host_cpu']}" unless os && arch
14
+
15
+ "#{os}-#{arch}"
16
+ end
17
+
18
+ def self.windows?(platform)
19
+ platform.start_with?("windows")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Enola
6
+ class Probe
7
+ SURFACES = %w[constraints providers check plan hook].freeze
8
+
9
+ def initialize(binary)
10
+ @binary = binary
11
+ end
12
+
13
+ def version
14
+ out, status = Open3.capture2e(@binary, "--version")
15
+ return nil unless status.success?
16
+
17
+ out[/\d+\.\d+\.\d+(?:[.-][\w.]+)?/]
18
+ rescue SystemCallError
19
+ nil
20
+ end
21
+
22
+ # A surface the binary has prints its usage (exit code 2 is its convention
23
+ # for --help); one it lacks is refused by the top-level dispatch by name.
24
+ def capabilities
25
+ SURFACES.to_h do |surface|
26
+ out, = Open3.capture2e(@binary, surface, "--help")
27
+ [surface.to_sym, !out.include?("unknown command \"#{surface}\"")]
28
+ rescue SystemCallError
29
+ [surface.to_sym, false]
30
+ end
31
+ end
32
+
33
+ def report(channel: Enola.channel)
34
+ { channel: channel.name, pinned: channel.version, binary: @binary, version: version, capabilities: capabilities }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ # Vendored from enola-labs/enola examples/providers/ruby/prism/enola_prism_provider.rb at v0.4.4 (1e35dfd88e7b293bb2bffec6949989bd6105b796), Apache-2.0.
4
+
5
+ # enola Prism reference provider.
6
+ #
7
+ # Parses every **/*.rb under the path given as ARGV[0] with Prism (the Ruby
8
+ # parser that ships in the standard library from Ruby 3.3) and emits JSONL
9
+ # call facts on stdout in enola's fact schema — one fact per method call,
10
+ # typed by what the receiver resolves to LEXICALLY. No runtime, no gems, no
11
+ # guessing:
12
+ #
13
+ # receiver is a constant -> callee "Const#method", resolution_level "constant-receiver"
14
+ # self or no receiver, inside a def
15
+ # nested in a nameable class/module -> callee "Enclosing#method", resolution_level "lexical-self"
16
+ # anything else -> callee "method", resolution_level "name-only"
17
+ #
18
+ # Fact naming is designed to ADD typed call edges without colliding with the
19
+ # identities enola's own Ruby extractor emits — the extractor already owns the
20
+ # symbol facts, and the seam skips any provider fact sharing a name+kind
21
+ # identity with an extractor fact. Every fact here is therefore kind
22
+ # "dependency" with the distinctive name "prism-call: <caller> -> <callee>",
23
+ # carrying one calls relation targeting the callee. The seam stamps
24
+ # provider/provider_version onto accepted facts; this script declares only
25
+ # resolution_level, which the seam requires on every provider fact.
26
+ #
27
+ # Determinism: files are enumerated in sorted order, output lines are sorted
28
+ # before printing, and nothing time- or environment-dependent is emitted.
29
+ # vendor/, node_modules/ and tmp/ subtrees are skipped at any depth; a file
30
+ # Prism cannot parse cleanly is skipped whole — fail closed, never guess.
31
+ # --version prints a fixed semver on stdout and exits, which is how the seam
32
+ # learns what build it is talking to.
33
+
34
+ require "json"
35
+ require "prism"
36
+
37
+ PROVIDER_VERSION = "0.1.0"
38
+
39
+ if ARGV.include?("--version")
40
+ puts PROVIDER_VERSION
41
+ exit 0
42
+ end
43
+
44
+ root = ARGV[0]
45
+ abort "usage: enola_prism_provider.rb <repo-path>" unless root && File.directory?(root)
46
+
47
+ SKIP_SEGMENTS = %w[vendor node_modules tmp].freeze
48
+
49
+ class CallCollector < Prism::Visitor
50
+ attr_reader :facts
51
+
52
+ def initialize(file)
53
+ @file = file
54
+ @scopes = []
55
+ @methods = []
56
+ @facts = []
57
+ @chained = []
58
+ super()
59
+ end
60
+
61
+ def visit_class_node(node)
62
+ with_scope(constant_name(node.constant_path)) { super }
63
+ end
64
+
65
+ def visit_module_node(node)
66
+ with_scope(constant_name(node.constant_path)) { super }
67
+ end
68
+
69
+ # An eigenclass body (class << self) pushes an unnameable scope: calls inside
70
+ # it degrade to name-only rather than being attributed to a name this script
71
+ # cannot lexically prove.
72
+ def visit_singleton_class_node(node)
73
+ with_scope(nil) { super }
74
+ end
75
+
76
+ def visit_def_node(node)
77
+ @methods.push(node.name.to_s)
78
+ super
79
+ @methods.pop
80
+ end
81
+
82
+ def visit_call_node(node)
83
+ record(node)
84
+ receiver = node.receiver
85
+ chained = receiver.is_a?(Prism::CallNode) && receiver.name.to_s == "new" ? node.name.to_s : nil
86
+ @chained.push(chained)
87
+ super
88
+ @chained.pop
89
+ end
90
+
91
+ private
92
+
93
+ def with_scope(name)
94
+ @scopes.push(name)
95
+ yield
96
+ @scopes.pop
97
+ end
98
+
99
+ def enclosing
100
+ return nil if @scopes.empty? || @scopes.any?(&:nil?)
101
+
102
+ @scopes.join("::")
103
+ end
104
+
105
+ def caller_name
106
+ enc = enclosing
107
+ return @file unless enc
108
+ return enc if @methods.empty?
109
+
110
+ "#{enc}##{@methods.last}"
111
+ end
112
+
113
+ def constant_name(node)
114
+ case node
115
+ when Prism::ConstantReadNode
116
+ node.name.to_s
117
+ when Prism::ConstantPathNode
118
+ begin
119
+ node.full_name
120
+ rescue StandardError
121
+ nil
122
+ end
123
+ end
124
+ end
125
+
126
+ def record(node)
127
+ method = node.name.to_s
128
+ receiver = node.receiver
129
+ return record_instantiation(node, receiver) if method == "new" && receiver && constant_name(receiver)
130
+
131
+ callee, level =
132
+ if receiver && (const = constant_name(receiver))
133
+ ["#{const}##{method}", "constant-receiver"]
134
+ elsif (receiver.nil? || receiver.is_a?(Prism::SelfNode)) && !@methods.empty? && (enc = enclosing)
135
+ ["#{enc}##{method}", "lexical-self"]
136
+ else
137
+ [method, "name-only"]
138
+ end
139
+ @facts << {
140
+ "kind" => "dependency",
141
+ "name" => "prism-call: #{caller_name} -> #{callee}",
142
+ "file" => @file,
143
+ "line" => node.location.start_line,
144
+ "props" => { "resolution_level" => level },
145
+ "relations" => [{ "kind" => "calls", "target" => callee }]
146
+ }
147
+ end
148
+
149
+ # A `new` on a literal constant is the one call whose result has a knowable
150
+ # type, so it is recorded as an instantiation rather than a call. When the
151
+ # instantiation is itself the receiver of a further call the ceremony
152
+ # `Foo.new(...).bar` is named on the fact, which is what a rule about
153
+ # one-shot objects reads.
154
+ def record_instantiation(node, receiver)
155
+ const = constant_name(receiver)
156
+ props = { "resolution_level" => "constant-receiver" }
157
+ props["one_shot_call"] = @chained.last unless @chained.empty? || @chained.last.nil?
158
+ @facts << {
159
+ "kind" => "dependency",
160
+ "name" => "prism-new: #{caller_name} -> #{const}",
161
+ "file" => @file,
162
+ "line" => node.location.start_line,
163
+ "props" => props,
164
+ "relations" => [{ "kind" => "instantiates", "target" => const }]
165
+ }
166
+ end
167
+ end
168
+
169
+ lines = []
170
+ files = Dir.glob(File.join("**", "*.rb"), base: root).reject do |rel|
171
+ rel.split("/").any? { |segment| SKIP_SEGMENTS.include?(segment) }
172
+ end.sort
173
+
174
+ files.each do |rel|
175
+ source = begin
176
+ File.read(File.join(root, rel))
177
+ rescue StandardError
178
+ next
179
+ end
180
+ result = Prism.parse(source)
181
+ next unless result.success?
182
+
183
+ collector = CallCollector.new(rel)
184
+ result.value.accept(collector)
185
+ collector.facts.each { |fact| lines << JSON.generate(fact) }
186
+ end
187
+
188
+ puts lines.sort
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "rbconfig"
6
+
7
+ module Enola
8
+ # Prism and Rubydex are on by default. Prism runs as the vendored upstream
9
+ # script under the Ruby running this gem; Rubydex is built into the binary
10
+ # and needs its engine library fetched once per binary version.
11
+ module Providers
12
+ RUBYDEX_EXPECTED_VERSION = "0.4.0"
13
+
14
+ def self.prism_script
15
+ File.expand_path("providers/prism/enola_prism_provider.rb", __dir__)
16
+ end
17
+
18
+ def self.ruby
19
+ RbConfig.ruby
20
+ end
21
+
22
+ def self.prism_version
23
+ out, status = Open3.capture2(ruby, prism_script, "--version")
24
+ status.success? ? out.strip : nil
25
+ end
26
+
27
+ def self.rubydex_expected_version(binary = nil)
28
+ return RUBYDEX_EXPECTED_VERSION unless binary
29
+
30
+ out, status = Open3.capture2(binary, "providers", "list")
31
+ return RUBYDEX_EXPECTED_VERSION unless status.success?
32
+
33
+ out[/rubydex\s+(\d+\.\d+\.\d+)/, 1] || RUBYDEX_EXPECTED_VERSION
34
+ end
35
+
36
+ # Runs `providers fetch rubydex` once per binary version; the outcome is
37
+ # remembered beside the binary so a session never asks twice, and a
38
+ # failure is a named skip the graph will also report, never silence.
39
+ def self.ensure_rubydex(binary, channel: Enola.channel, cache_root: Enola.cache_root, stderr: $stderr)
40
+ marker = File.join(channel.cache_dir(cache_root), "providers-rubydex")
41
+ return File.read(marker).strip if File.exist?(marker)
42
+
43
+ out, status = Open3.capture2e(binary, "providers", "fetch", "rubydex")
44
+ outcome = status.success? ? "fetched" : "skipped: #{out.lines.last&.strip || 'providers fetch rubydex failed'}"
45
+ stderr.puts "enola: rubydex #{outcome}; Rubydex facts will be a named skip in the receipt until it succeeds" unless status.success?
46
+ FileUtils.mkdir_p(File.dirname(marker))
47
+ File.write(marker, "#{outcome}\n")
48
+ outcome
49
+ rescue SystemCallError => e
50
+ "skipped: #{e.message}"
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enola
4
+ class Resolver
5
+ Found = Struct.new(:path, :source)
6
+
7
+ def initialize(channel: Enola.channel, cache_root: Enola.cache_root, platform: Platform.current, path: ENV.fetch("PATH", ""))
8
+ @channel = channel
9
+ @fetcher = Fetcher.new(channel: channel, cache_root: cache_root, platform: platform)
10
+ @path = path
11
+ end
12
+
13
+ def resolve
14
+ return Found.new(@fetcher.binary_path, "cache") if @fetcher.cached?
15
+
16
+ on_path, found_version = matching_path_binary
17
+ return Found.new(on_path, "path") if on_path
18
+
19
+ begin
20
+ Found.new(@fetcher.fetch, "fetched")
21
+ rescue Unavailable => e
22
+ raise Unavailable, found_version ? "#{e.message}; the enola on PATH is #{found_version}, the Gemfile pins #{@channel.version}" : e.message
23
+ end
24
+ end
25
+
26
+ EXECUTABLE = File.expand_path("../../exe/enola", __dir__)
27
+
28
+ private
29
+
30
+ def matching_path_binary
31
+ candidate = @path.split(File::PATH_SEPARATOR).map { |dir| File.join(dir, "enola") }
32
+ .find { |bin| File.executable?(bin) && !wrapper?(bin) }
33
+ return [nil, nil] unless candidate
34
+
35
+ version = Probe.new(candidate).version
36
+ version == @channel.version ? [candidate, version] : [nil, version]
37
+ end
38
+
39
+ # Bundler puts this gem's own exe on PATH, and a rubygems binstub for the
40
+ # gem looks like a binary too; probing either would run the wrapper inside
41
+ # itself without end.
42
+ def wrapper?(bin)
43
+ return true if File.realpath(bin) == File.realpath(EXECUTABLE)
44
+
45
+ head = File.binread(bin, 512)
46
+ head.include?("Gem.activate_bin_path") || head.include?('require "enola"')
47
+ rescue SystemCallError
48
+ true
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enola
4
+ class Runner
5
+ def initialize(resolver: Enola.resolver, channel: Enola.channel, stderr: $stderr)
6
+ @resolver = resolver
7
+ @channel = channel
8
+ @stderr = stderr
9
+ end
10
+
11
+ def command(argv)
12
+ found = @resolver.resolve
13
+ Providers.ensure_rubydex(found.path, channel: @channel, stderr: @stderr)
14
+ @stderr.puts "enola: #{@channel} via #{found.source} (#{found.path})" if argv.include?("--verbose")
15
+ [found.path, *argv]
16
+ end
17
+
18
+ def run(argv)
19
+ system(*command(argv))
20
+ $?.exitstatus || 1
21
+ end
22
+
23
+ def exec(argv)
24
+ Kernel.exec(*command(argv))
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enola
4
+ # The release this wrapper drives. VERSION carries a patch segment for
5
+ # wrapper-only fixes, so what to fetch is stated separately.
6
+ UPSTREAM_VERSION = "0.4.4"
7
+ VERSION = "#{UPSTREAM_VERSION}.1"
8
+ end
data/lib/enola.rb CHANGED
@@ -1,5 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module EnolaPlaceholder
4
- VERSION = "0.0.0"
3
+ require_relative "enola/version"
4
+ require_relative "enola/error"
5
+ require_relative "enola/channel"
6
+ require_relative "enola/platform"
7
+ require_relative "enola/fetcher"
8
+ require_relative "enola/probe"
9
+ require_relative "enola/providers"
10
+ require_relative "enola/config"
11
+ require_relative "enola/resolver"
12
+ require_relative "enola/runner"
13
+ require_relative "enola/cli"
14
+
15
+ module Enola
16
+ class << self
17
+ attr_writer :channel
18
+
19
+ # Another channel's gem installs its own resolver here, so every rake
20
+ # task and wrapped command built on Runner drives that channel's binary.
21
+ # Readable as well as writable: whoever replaces it can put back what was
22
+ # installed rather than what it assumed.
23
+ attr_accessor :resolver_factory
24
+
25
+ def resolver
26
+ (@resolver_factory || -> { Resolver.new(channel: channel) }).call
27
+ end
28
+
29
+ def channel
30
+ @channel ||= Channel::UPSTREAM
31
+ end
32
+
33
+ def cache_root
34
+ ENV.fetch("ENOLA_CACHE_DIR") { File.join(Dir.home, ".cache", "enola") }
35
+ end
36
+ end
5
37
  end
metadata CHANGED
@@ -1,35 +1,66 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: enola
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.4.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Muhamed Isabegovic
8
8
  autorequire:
9
- bindir: bin
9
+ bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-21 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2026-08-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: prism
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
13
27
  description: |
14
- This gem is a placeholder, not an implementation. enola is an
15
- architecture-graph tool written in Go by enola-labs, which has no Ruby
16
- package. This name is held so that it stays available to that project, and
17
- will be transferred to its maintainers on request. For Ruby usage today,
18
- see the enola-rb gem.
28
+ A pure-Ruby wrapper around enola, the architecture-graph tool by enola-labs.
29
+ The gem's version is the upstream release it drives. The binary is not in
30
+ the gem: the first command that needs it downloads the release for your
31
+ platform, verifies it against the checksum the release publishes, and keeps
32
+ it in a per-user cache. Every command and exit code is forwarded unchanged.
33
+ This gem is not an enola-labs release.
19
34
  email:
20
35
  - m.isabegovic@hotmail.com
21
- executables: []
36
+ executables:
37
+ - enola
22
38
  extensions: []
23
39
  extra_rdoc_files: []
24
40
  files:
41
+ - CHANGELOG.md
25
42
  - LICENSE
26
43
  - README.md
44
+ - exe/enola
27
45
  - lib/enola.rb
28
- homepage: https://github.com/enola-labs/enola
46
+ - lib/enola/channel.rb
47
+ - lib/enola/cli.rb
48
+ - lib/enola/config.rb
49
+ - lib/enola/error.rb
50
+ - lib/enola/fetcher.rb
51
+ - lib/enola/platform.rb
52
+ - lib/enola/probe.rb
53
+ - lib/enola/providers.rb
54
+ - lib/enola/providers/prism/enola_prism_provider.rb
55
+ - lib/enola/resolver.rb
56
+ - lib/enola/runner.rb
57
+ - lib/enola/version.rb
58
+ homepage: https://github.com/misabegovic/enola-rb
29
59
  licenses:
30
60
  - Apache-2.0
31
61
  metadata:
32
62
  source_code_uri: https://github.com/misabegovic/enola-rb
63
+ changelog_uri: https://github.com/misabegovic/enola-rb/blob/main/CHANGELOG.md
33
64
  post_install_message:
34
65
  rdoc_options: []
35
66
  require_paths:
@@ -48,5 +79,6 @@ requirements: []
48
79
  rubygems_version: 3.5.22
49
80
  signing_key:
50
81
  specification_version: 4
51
- summary: Name placeholder held for the enola project; not an official release.
82
+ summary: 'Runs the released enola from Ruby: fetched on first use, verified, pinned
83
+ to this gem''s version.'
52
84
  test_files: []