munola 0.0.0 → 0.5.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/CHANGELOG.md +78 -0
- data/LICENSE +1 -1
- data/README.md +125 -8
- data/exe/munola +6 -0
- data/lib/munola/catalogue.rb +47 -0
- data/lib/munola/channel.rb +37 -0
- data/lib/munola/cli.rb +37 -0
- data/lib/munola/detector.rb +83 -0
- data/lib/munola/installer.rb +116 -0
- data/lib/munola/providers_config.rb +50 -0
- data/lib/munola/version.rb +9 -0
- data/lib/munola.rb +12 -1
- metadata +67 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3fef04df27a6176d9c98ef6a96588ab444f63e92a6ef3c3c3fd73268af931de3
|
|
4
|
+
data.tar.gz: 8190ecb25656352775ea9ead33c18e04c7faddaac23d67644045398fea80ce26
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7524cf02ef40ba7136a5ff531ffda09c2e1056b9a53b73710728be0ad64a7158d3aeccf94492a7401619776d2a44a07e22bcff7511ac775083112de200df4820
|
|
7
|
+
data.tar.gz: fcf9fc3d4ac887817ad8dab39f5c071d4569a282a12fa2720bd5f935da62a4793e6a833c45032cead51f653603baf675f78f2f064351a8bae23516f912a9e883
|
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/LICENSE
CHANGED
|
@@ -187,7 +187,7 @@ APPENDIX: How to apply the Apache License to your work.
|
|
|
187
187
|
same "printed page" as the copyright notice for easier
|
|
188
188
|
identification within third-party archives.
|
|
189
189
|
|
|
190
|
-
Copyright (c) 2026
|
|
190
|
+
Copyright (c) 2026 Muhamed Isabegovic
|
|
191
191
|
|
|
192
192
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
193
|
you may not use this file except in compliance with the License.
|
data/README.md
CHANGED
|
@@ -1,13 +1,130 @@
|
|
|
1
1
|
# enola-rb
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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,
|
|
7
|
-
enola
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
[enola-guides](https://github.com/misabegovic/enola-guides).
|
|
12
|
+
## `enola`
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
The wrapper of the released enola. The gem versions itself; the release it
|
|
15
|
+
drives is `Enola::UPSTREAM_VERSION`, v0.4.4 as of `enola 0.5.0`, and every
|
|
16
|
+
gemspec summary names it.
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "enola"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bundle exec enola --generate .
|
|
24
|
+
bundle exec enola baseline pin .
|
|
25
|
+
bundle exec enola check .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The first command that needs the binary downloads the release for your
|
|
29
|
+
platform (linux, darwin, windows; amd64, arm64), verifies it against the
|
|
30
|
+
sha256 file the same release publishes, and keeps it under
|
|
31
|
+
`~/.cache/enola/upstream/0.4.4/` (`ENOLA_CACHE_DIR` moves the root). An
|
|
32
|
+
`enola` already on your PATH is used only when it answers the pinned version;
|
|
33
|
+
offline with an empty cache is a refusal that names the cache and the release
|
|
34
|
+
rather than a fallback to whatever is installed. Nothing downloads at
|
|
35
|
+
`bundle install`.
|
|
36
|
+
|
|
37
|
+
Every argument and exit code is forwarded unchanged; the wrapper adds no flag
|
|
38
|
+
of its own to enola's surface. `--verbose` on any command writes one line to
|
|
39
|
+
stderr first, naming the channel, the version and where the binary came from.
|
|
40
|
+
`enola --wrapper-probe` prints the channel, the pin, the version the binary
|
|
41
|
+
answers and which surfaces it has (`constraints`, `providers`, `check`,
|
|
42
|
+
`hook`), read by running them, never by comparing version strings.
|
|
43
|
+
|
|
44
|
+
This gem is not an enola-labs release; it runs theirs.
|
|
45
|
+
|
|
46
|
+
## `enola-rb`
|
|
47
|
+
|
|
48
|
+
The Rails layer over `enola`. It depends on `enola` at the same minor and on
|
|
49
|
+
[enola-guides](https://github.com/misabegovic/enola-guides), and adds nothing
|
|
50
|
+
to the binary's surface.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
gem "enola-rb"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
bin/rails generate enola:install
|
|
58
|
+
bin/rake enola:snapshot
|
|
59
|
+
bin/rake enola:check
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The generator writes `enola/constraints/` from the guides' starter laws (four
|
|
63
|
+
laws a Rails team keeps, each with its reason), asks the binary's own
|
|
64
|
+
`constraints init` to bind the shipped recipes whose roles resolve in the app,
|
|
65
|
+
writes every other shipped recipe as a commented binding to uncomment once the
|
|
66
|
+
directories exist, and ignores `.enola/`. It reads what `init` wrote rather
|
|
67
|
+
than its exit code, and when the binary cannot be fetched it still writes the
|
|
68
|
+
laws and says so. `enola:snapshot` generates and pins the baseline;
|
|
69
|
+
`enola:check` grades the working tree against it and fails on a new breach of
|
|
70
|
+
a declared law. A surface the pinned binary lacks is refused by name with the
|
|
71
|
+
remedy.
|
|
72
|
+
|
|
73
|
+
## `munola`
|
|
74
|
+
|
|
75
|
+
One person's taste on top of enola: the same wrapper over another channel,
|
|
76
|
+
the builds cut from [a fork of enola](https://github.com/misabegovic/enola),
|
|
77
|
+
each release naming what differs from that upstream. The gem versions itself;
|
|
78
|
+
the channel release it fetches is `Munola::CHANNEL_VERSION`, which carries the
|
|
79
|
+
upstream it is built on plus a segment of its own (`0.4.4.2` on v0.4.4). It is
|
|
80
|
+
offered upstream where it fits and is not positioned against it. What it adds is the channel and the catalogue.
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
gem "munola"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
bundle exec munola init . --tenant-column company_id
|
|
88
|
+
bundle exec munola --version
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`munola init` does what `enola:install` does, then what only munola carries:
|
|
92
|
+
it writes the recipe catalogue the `enola-guides` gem ships (Ember
|
|
93
|
+
conventions, data ownership, API boundaries, background work, a tenant
|
|
94
|
+
foreign key) into `enola/recipes/`, binds the recipes the tree justifies
|
|
95
|
+
(`ember-cli-build.js`, a schema with `app/models`, `config/routes.rb` with
|
|
96
|
+
`app/policies`, `app/tasks`, a column most tables share confirmed against
|
|
97
|
+
`db/schema.rb`) and writes every other one as a commented binding; it fills
|
|
98
|
+
the tenant template from the schema's own table names, never from an
|
|
99
|
+
inflection table; it writes `mcp-arch.yaml` with both Ruby providers on by
|
|
100
|
+
default, Prism through the script the `enola` gem carries and Rubydex built
|
|
101
|
+
into the binary, and fetches the Rubydex library, a failed fetch reported as a
|
|
102
|
+
named skip. It never asks a question. `munola --version` names the munola
|
|
103
|
+
version, the channel release and upstream it is built on, and which binary
|
|
104
|
+
answered.
|
|
105
|
+
|
|
106
|
+
The binary comes from the fork's releases the way `enola`'s comes from
|
|
107
|
+
upstream's, fetched on first use and verified against the sha256 the release
|
|
108
|
+
publishes; `MUNOLA_BINARY=/path/to/enola` names one to drive instead, and
|
|
109
|
+
every command says which answered. `munola` depends on `enola` at the same
|
|
110
|
+
minor and on `enola-guides` 0.3.1 or later; requiring it loads no Rails.
|
|
111
|
+
|
|
112
|
+
## `munola-rb`
|
|
113
|
+
|
|
114
|
+
The Rails layer over `munola`, what `enola-rb` is to `enola`. It depends on
|
|
115
|
+
both at the same minor and adds one generator.
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
gem "munola-rb"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
bin/rails generate munola:install --tenant-column company_id
|
|
123
|
+
bin/rake enola:snapshot
|
|
124
|
+
bin/rake enola:check
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The generator runs the same install `munola init` does. The rake tasks are
|
|
128
|
+
enola-rb's and need no munola copy: `munola` installs its own resolver when it
|
|
129
|
+
loads, so `enola:snapshot` and `enola:check` drive the munola binary in an app
|
|
130
|
+
that has this gem, and the upstream binary in one that does not.
|
data/exe/munola
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Munola
|
|
6
|
+
# The recipes munola ships built in, read from the enola-guides gem so the
|
|
7
|
+
# content has one version line; the template carries a value the installer
|
|
8
|
+
# fills in from what the app shows.
|
|
9
|
+
module Catalogue
|
|
10
|
+
RECIPES = %w[ember-conventions data-ownership api-boundaries background-work tenant-foreign-key].freeze
|
|
11
|
+
TEMPLATES = %w[tenant-foreign-key].freeze
|
|
12
|
+
PLACEHOLDERS = { column: "TENANT_COLUMN", table: "TENANT_TABLE" }.freeze
|
|
13
|
+
|
|
14
|
+
def self.source_dir
|
|
15
|
+
require "enola-rb"
|
|
16
|
+
File.join(EnolaRb::Guides.root, "recipes")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.source(name)
|
|
20
|
+
path = File.join(source_dir, "#{name}.yaml")
|
|
21
|
+
raise Enola::Unavailable, "the installed enola-guides gem has no recipe #{name}; it needs 0.3.1 or later" unless File.exist?(path)
|
|
22
|
+
|
|
23
|
+
path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Writes every catalogue recipe the project does not already have into
|
|
27
|
+
# enola/recipes/, the template with its values filled or, without them,
|
|
28
|
+
# left as it is so the placeholders show what to decide.
|
|
29
|
+
def self.write(root, tenant: nil)
|
|
30
|
+
dir = File.join(root, "enola", "recipes")
|
|
31
|
+
FileUtils.mkdir_p(dir)
|
|
32
|
+
RECIPES.each_with_object([]) do |name, written|
|
|
33
|
+
target = File.join(dir, "#{name}.yaml")
|
|
34
|
+
next if File.exist?(target)
|
|
35
|
+
|
|
36
|
+
body = File.read(source(name))
|
|
37
|
+
body = fill(body, tenant) if TEMPLATES.include?(name) && tenant
|
|
38
|
+
File.write(target, body)
|
|
39
|
+
written << target
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.fill(body, tenant)
|
|
44
|
+
body.gsub(PLACEHOLDERS[:column], tenant.fetch(:column)).gsub(PLACEHOLDERS[:table], tenant.fetch(:table))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Munola
|
|
4
|
+
CHANNEL = Enola::Channel.new(
|
|
5
|
+
name: "munola",
|
|
6
|
+
release_base: "https://github.com/misabegovic/enola/releases/download",
|
|
7
|
+
version: CHANNEL_VERSION,
|
|
8
|
+
asset_prefix: "enola",
|
|
9
|
+
tag_prefix: "munola-v"
|
|
10
|
+
).freeze
|
|
11
|
+
|
|
12
|
+
# The binary this channel drives is fetched from the fork's releases; until
|
|
13
|
+
# the first one is cut, MUNOLA_BINARY names a binary to drive instead, and
|
|
14
|
+
# every command says which one answered.
|
|
15
|
+
class Resolver < Enola::Resolver
|
|
16
|
+
def initialize(override: ENV.fetch("MUNOLA_BINARY", nil), **options)
|
|
17
|
+
super(**options)
|
|
18
|
+
@override = override
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve
|
|
22
|
+
return super unless @override
|
|
23
|
+
|
|
24
|
+
raise Enola::Unavailable, "MUNOLA_BINARY names #{@override}, which is not executable" unless File.executable?(@override)
|
|
25
|
+
|
|
26
|
+
Found.new(@override, "MUNOLA_BINARY (#{Enola::Probe.new(@override).version || 'no version answered'})")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.resolve
|
|
31
|
+
Resolver.new(channel: CHANNEL).resolve
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.channel_line(found = resolve)
|
|
35
|
+
"munola #{VERSION}, channel #{CHANNEL_VERSION} on enola #{UPSTREAM_VERSION}; binary via #{found.source} (#{found.path})"
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/munola/cli.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Munola
|
|
6
|
+
module CLI
|
|
7
|
+
def self.start(argv, stdout: $stdout, stderr: $stderr)
|
|
8
|
+
case argv.first
|
|
9
|
+
when "--version"
|
|
10
|
+
found = Munola.resolve
|
|
11
|
+
out, = Open3.capture2e(found.path, "--version")
|
|
12
|
+
stdout.puts Munola.channel_line(found)
|
|
13
|
+
stdout.puts out.strip
|
|
14
|
+
0
|
|
15
|
+
when "init"
|
|
16
|
+
init(argv.drop(1), stdout: stdout, stderr: stderr)
|
|
17
|
+
else
|
|
18
|
+
Enola::Runner.new(channel: CHANNEL, stderr: stderr).exec(argv)
|
|
19
|
+
end
|
|
20
|
+
rescue Enola::Error => e
|
|
21
|
+
stderr.puts "munola: #{e.message}"
|
|
22
|
+
127
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.init(args, stdout:, stderr:)
|
|
26
|
+
options = { tenant_column: nil }
|
|
27
|
+
parser = OptionParser.new do |opts|
|
|
28
|
+
opts.banner = "Usage: munola init [ROOT] [--tenant-column COLUMN]"
|
|
29
|
+
opts.on("--tenant-column COLUMN", "The column most tables carry; confirmed against db/schema.rb") { |v| options[:tenant_column] = v }
|
|
30
|
+
end
|
|
31
|
+
rest = parser.parse(args)
|
|
32
|
+
root = rest.first || Dir.pwd
|
|
33
|
+
Installer.new(root, tenant_column: options[:tenant_column], stderr: stderr).install.lines.each { |line| stdout.puts line }
|
|
34
|
+
0
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Munola
|
|
4
|
+
# Reads the tree for what the binary's own init cannot see: which catalogue
|
|
5
|
+
# recipes have the directories their roles need, and which column most
|
|
6
|
+
# tables share. It reads files; it never asks.
|
|
7
|
+
class Detector
|
|
8
|
+
Tenant = Struct.new(:column, :table, :share, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(root)
|
|
11
|
+
@root = File.expand_path(root)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def bindings
|
|
15
|
+
found = []
|
|
16
|
+
found << "ember-conventions" if file?("ember-cli-build.js")
|
|
17
|
+
found << "data-ownership" if schema? && dir?("app/models")
|
|
18
|
+
found << "api-boundaries" if file?("config/routes.rb") && dir?("app/policies")
|
|
19
|
+
found << "background-work" if dir?("app/tasks")
|
|
20
|
+
found
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def schema?
|
|
24
|
+
file?("db/schema.rb") || file?("db/structure.sql")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The tenant column is the one present on most tables; a column the
|
|
28
|
+
# caller names is confirmed against the schema rather than trusted.
|
|
29
|
+
def tenant(column: nil)
|
|
30
|
+
tables = schema_tables
|
|
31
|
+
return nil if tables.empty?
|
|
32
|
+
|
|
33
|
+
counts = Hash.new(0)
|
|
34
|
+
tables.each_value { |columns| columns.uniq.each { |name| counts[name] += 1 } }
|
|
35
|
+
candidate = column || counts.select { |name, _| name.end_with?("_id") }.max_by { |name, count| [count, -name.length] }&.first
|
|
36
|
+
return nil unless candidate
|
|
37
|
+
|
|
38
|
+
share = counts.fetch(candidate, 0).fdiv(tables.size)
|
|
39
|
+
return nil if share < 0.5 && column.nil?
|
|
40
|
+
|
|
41
|
+
table = tenant_table(candidate, tables.keys)
|
|
42
|
+
Tenant.new(column: candidate, table: table, share: share)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def file?(rel)
|
|
48
|
+
File.file?(File.join(@root, rel))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def dir?(rel)
|
|
52
|
+
File.directory?(File.join(@root, rel))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def schema_tables
|
|
56
|
+
path = File.join(@root, "db", "schema.rb")
|
|
57
|
+
return {} unless File.file?(path)
|
|
58
|
+
|
|
59
|
+
tables = {}
|
|
60
|
+
current = nil
|
|
61
|
+
File.foreach(path) do |line|
|
|
62
|
+
if (m = line.match(/create_table\s+"([^"]+)"/))
|
|
63
|
+
current = m[1]
|
|
64
|
+
tables[current] = []
|
|
65
|
+
elsif current && (m = line.match(/^\s*t\.\w+\s+"([^"]+)"/))
|
|
66
|
+
tables[current] << m[1]
|
|
67
|
+
elsif line.strip == "end"
|
|
68
|
+
current = nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
tables
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# company_id names companies, category_id names categories, person_id
|
|
75
|
+
# names people only if the schema has that table: the table is looked up
|
|
76
|
+
# among the schema's own names, never guessed from an inflection table.
|
|
77
|
+
def tenant_table(column, names)
|
|
78
|
+
stem = column.delete_suffix("_id")
|
|
79
|
+
candidates = [stem, "#{stem}s", "#{stem}es", stem.sub(/y\z/, "ies")]
|
|
80
|
+
names.find { |name| candidates.include?(name) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module Munola
|
|
8
|
+
class Installer
|
|
9
|
+
Report = Struct.new(:written, :bound, :commented, :notes, keyword_init: true) do
|
|
10
|
+
def lines
|
|
11
|
+
out = written.map { |path| "wrote #{path}" }
|
|
12
|
+
out << "bound #{bound.join(', ')}" unless bound.empty?
|
|
13
|
+
out << "left commented, bind when the directories exist: #{commented.join(', ')}" unless commented.empty?
|
|
14
|
+
out + notes
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(root, tenant_column: nil, binary: nil, stderr: $stderr)
|
|
19
|
+
@root = File.expand_path(root)
|
|
20
|
+
@tenant_column = tenant_column
|
|
21
|
+
@binary = binary
|
|
22
|
+
@stderr = stderr
|
|
23
|
+
@report = Report.new(written: [], bound: [], commented: [], notes: [])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The Rails layer first (starter laws, the binary's own init, upstream's
|
|
27
|
+
# recipes commented), then what only munola carries: the catalogue, the
|
|
28
|
+
# bindings the tree justifies, the providers both on by default.
|
|
29
|
+
# enola-rb carries the installer this reuses; it is required here rather
|
|
30
|
+
# than at load time so `require "munola"` stays a wrapper and pulls in no
|
|
31
|
+
# Rails machinery for a project that only drives the binary.
|
|
32
|
+
def install
|
|
33
|
+
require "enola-rb"
|
|
34
|
+
rails = EnolaRb::Installer.new(@root, binary: resolved_binary, stderr: @stderr).install
|
|
35
|
+
@report.written.concat(rails.written)
|
|
36
|
+
@report.notes.concat(rails.notes)
|
|
37
|
+
|
|
38
|
+
detector = Detector.new(@root)
|
|
39
|
+
tenant = detector.tenant(column: @tenant_column)
|
|
40
|
+
values = tenant&.table ? { column: tenant.column, table: tenant.table } : nil
|
|
41
|
+
@report.written.concat(Catalogue.write(@root, tenant: values).map { |p| relative(p) })
|
|
42
|
+
bind(detector.bindings + (tenant&.table ? ["tenant-foreign-key"] : []))
|
|
43
|
+
note_tenant(tenant)
|
|
44
|
+
providers
|
|
45
|
+
@report.bound = (rails.bound + @bound).uniq
|
|
46
|
+
@report.commented = rails.unbound + @commented
|
|
47
|
+
@report
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def resolved_binary
|
|
53
|
+
@binary ||= Munola.resolve.path
|
|
54
|
+
rescue Enola::Error
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def bindings_path
|
|
59
|
+
File.join(@root, "enola", "constraints", "recipes.yaml")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A munola binary carries the catalogue itself, so its own `constraints
|
|
63
|
+
# init` may already have bound a recipe this would bind again, and two
|
|
64
|
+
# instances of one recipe expand to colliding rule ids. What the file
|
|
65
|
+
# already names is left alone and counted as bound.
|
|
66
|
+
def bind(names)
|
|
67
|
+
@bound = names & Catalogue::RECIPES
|
|
68
|
+
@commented = Catalogue::RECIPES - @bound
|
|
69
|
+
existing = File.exist?(bindings_path) ? File.read(bindings_path) : "use_recipe: []\n"
|
|
70
|
+
return if existing.include?("# munola catalogue")
|
|
71
|
+
|
|
72
|
+
doc = YAML.safe_load(existing) || {}
|
|
73
|
+
declared = Array(doc["use_recipe"]).map { |entry| entry["recipe"] }.compact
|
|
74
|
+
entries = Array(doc["use_recipe"]) + (@bound - declared).map { |name| { "recipe" => name, "as" => name, "mode" => "advisory" } }
|
|
75
|
+
body = YAML.dump({ "use_recipe" => entries }).sub(/\A---\n/, "")
|
|
76
|
+
tail = existing[/\n# Shipped recipes not bound.*\z/m].to_s
|
|
77
|
+
commented = @commented.map { |name| "# - recipe: #{name}\n# as: #{name}\n# mode: advisory\n" }.join
|
|
78
|
+
File.write(bindings_path, "#{body}#{tail}\n# munola catalogue recipes not bound because the tree shows no need for them yet.\n" \
|
|
79
|
+
"# Uncomment one to switch it on, then run `enola constraints lint`.\n#{commented}")
|
|
80
|
+
@report.written << relative(bindings_path) unless @report.written.include?(relative(bindings_path))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def note_tenant(tenant)
|
|
84
|
+
if tenant&.table
|
|
85
|
+
note("tenant column #{tenant.column} on #{(tenant.share * 100).round}% of tables, referencing #{tenant.table}; tenant-foreign-key written with it")
|
|
86
|
+
elsif tenant && tenant.share.zero?
|
|
87
|
+
note("#{tenant.column} appears on no table in db/schema.rb; tenant-foreign-key keeps its placeholders")
|
|
88
|
+
elsif tenant
|
|
89
|
+
note("tenant column #{tenant.column} found but no table named for it in the schema; tenant-foreign-key keeps its TENANT_TABLE placeholder")
|
|
90
|
+
elsif @tenant_column
|
|
91
|
+
note("#{@tenant_column} appears on no table in db/schema.rb; tenant-foreign-key keeps its placeholders")
|
|
92
|
+
else
|
|
93
|
+
note("no column sits on most tables; tenant-foreign-key keeps its placeholders, pass --tenant-column to name one")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def providers
|
|
98
|
+
path = ProvidersConfig.write(@root)
|
|
99
|
+
if path
|
|
100
|
+
@report.written << relative(path)
|
|
101
|
+
note("prism provider: the enola gem carries no provider script yet; mcp-arch.yaml names the slot") unless ProvidersConfig.prism_script
|
|
102
|
+
end
|
|
103
|
+
binary = resolved_binary
|
|
104
|
+
note(binary ? ProvidersConfig.fetch_rubydex(binary) : "rubydex library not fetched: no binary resolved; run `enola providers fetch rubydex` once it is")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def relative(path)
|
|
108
|
+
path.delete_prefix("#{@root}/")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def note(text)
|
|
112
|
+
@report.notes << text
|
|
113
|
+
@stderr.puts "munola: #{text}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module Munola
|
|
7
|
+
# Every munola project runs both Ruby providers by default: Prism through the
|
|
8
|
+
# script the enola gem carries, Rubydex built into the binary. The config is
|
|
9
|
+
# written once, never rewritten; the Rubydex library is fetched after the
|
|
10
|
+
# binary, and a fetch that fails is a named skip, never a silent absence.
|
|
11
|
+
module ProvidersConfig
|
|
12
|
+
RUBYDEX_VERSION = "0.4.0"
|
|
13
|
+
|
|
14
|
+
def self.write(root, ruby: RbConfig.ruby)
|
|
15
|
+
path = File.join(root, "mcp-arch.yaml")
|
|
16
|
+
return nil if File.exist?(path)
|
|
17
|
+
|
|
18
|
+
File.write(path, render(ruby))
|
|
19
|
+
path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.render(ruby)
|
|
23
|
+
lines = ["repo: .", "repos:", " - .", "providers:"]
|
|
24
|
+
script = prism_script
|
|
25
|
+
if script
|
|
26
|
+
lines += [" - name: prism", " command: [#{ruby.inspect}, #{script.inspect}]", " expected_version: \"0.1.0\""]
|
|
27
|
+
else
|
|
28
|
+
lines << " # prism: the enola gem on this machine carries no provider script; add one here to run it"
|
|
29
|
+
end
|
|
30
|
+
lines += [" - name: rubydex", " expected_version: \"#{RUBYDEX_VERSION}\""]
|
|
31
|
+
"#{lines.join("\n")}\n"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.prism_script
|
|
35
|
+
return nil unless defined?(Enola::Providers) && Enola::Providers.respond_to?(:prism_script)
|
|
36
|
+
|
|
37
|
+
script = Enola::Providers.prism_script
|
|
38
|
+
script if script && File.exist?(script)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.fetch_rubydex(binary)
|
|
42
|
+
out, status = Open3.capture2e(binary, "providers", "fetch", "rubydex")
|
|
43
|
+
return "rubydex #{RUBYDEX_VERSION} library in place" if status.success?
|
|
44
|
+
|
|
45
|
+
"rubydex library not fetched (#{out.lines.last.to_s.strip}); the provider reads as a named skip until `enola providers fetch rubydex` succeeds"
|
|
46
|
+
rescue SystemCallError => e
|
|
47
|
+
"rubydex library not fetched (#{e.message}); the provider reads as a named skip until `enola providers fetch rubydex` succeeds"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/munola.rb
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "enola"
|
|
4
|
+
require_relative "munola/version"
|
|
5
|
+
require_relative "munola/channel"
|
|
6
|
+
require_relative "munola/catalogue"
|
|
7
|
+
require_relative "munola/detector"
|
|
8
|
+
require_relative "munola/providers_config"
|
|
9
|
+
require_relative "munola/installer"
|
|
10
|
+
require_relative "munola/cli"
|
|
11
|
+
|
|
12
|
+
Enola.channel = Munola::CHANNEL
|
|
13
|
+
Enola.resolver_factory = -> { Munola::Resolver.new(channel: Munola::CHANNEL) }
|
|
14
|
+
|
|
3
15
|
module Munola
|
|
4
|
-
VERSION = "0.0.0"
|
|
5
16
|
end
|
metadata
CHANGED
|
@@ -1,29 +1,85 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: munola
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Muhamed Isabegovic
|
|
8
8
|
autorequire:
|
|
9
|
-
bindir:
|
|
9
|
+
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
12
|
-
dependencies:
|
|
11
|
+
date: 2026-08-23 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: enola
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 0.5.0
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 0.5.0
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: enola-rb
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: 0.5.0
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: 0.5.0
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: enola-guides
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: 0.3.1
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: 0.3.1
|
|
13
55
|
description: |
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
56
|
+
The same pure-Ruby wrapper as the enola gem over another channel: the
|
|
57
|
+
builds cut from a fork of enola, each release naming what differs. This
|
|
58
|
+
gem drives channel release 0.4.4.2, built on enola
|
|
59
|
+
0.4.4. `munola init` writes the recipe catalogue the
|
|
60
|
+
enola-guides gem carries (Ember, data ownership, API boundaries,
|
|
61
|
+
background work, a tenant foreign key) into the project and binds the
|
|
62
|
+
recipes its tree justifies, and turns both Ruby providers on by default. The Rails generator lives in munola-rb. Offered
|
|
63
|
+
upstream where it fits; no binary here, nothing compiled.
|
|
18
64
|
email:
|
|
19
65
|
- m.isabegovic@hotmail.com
|
|
20
|
-
executables:
|
|
66
|
+
executables:
|
|
67
|
+
- munola
|
|
21
68
|
extensions: []
|
|
22
69
|
extra_rdoc_files: []
|
|
23
70
|
files:
|
|
71
|
+
- CHANGELOG.md
|
|
24
72
|
- LICENSE
|
|
25
73
|
- README.md
|
|
74
|
+
- exe/munola
|
|
26
75
|
- lib/munola.rb
|
|
76
|
+
- lib/munola/catalogue.rb
|
|
77
|
+
- lib/munola/channel.rb
|
|
78
|
+
- lib/munola/cli.rb
|
|
79
|
+
- lib/munola/detector.rb
|
|
80
|
+
- lib/munola/installer.rb
|
|
81
|
+
- lib/munola/providers_config.rb
|
|
82
|
+
- lib/munola/version.rb
|
|
27
83
|
homepage: https://github.com/misabegovic/enola-rb
|
|
28
84
|
licenses:
|
|
29
85
|
- Apache-2.0
|
|
@@ -48,5 +104,6 @@ requirements: []
|
|
|
48
104
|
rubygems_version: 3.5.22
|
|
49
105
|
signing_key:
|
|
50
106
|
specification_version: 4
|
|
51
|
-
summary:
|
|
107
|
+
summary: 'One person''s taste on top of enola: a channel of its own and a recipe catalogue
|
|
108
|
+
bound by what the tree shows.'
|
|
52
109
|
test_files: []
|