catenary 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f94eb928dc59f5fe189c9c5e9b70a6fc1cb88cbc2fa2caf01af772afb2391738
4
+ data.tar.gz: ed2f22e05213d1cbd0667a3c5f0236e9aaa7d8b15bce247291c5857b882e52e0
5
+ SHA512:
6
+ metadata.gz: dea6b99eaf6e5760211cb2d4e2354a1777b84bae0e4b13f16eef185f7e3f543c28964ff492d746e350fba6992c11a74882294db8bd7d6cb560f0dfbd74ec7f94
7
+ data.tar.gz: 0e39aad88087cfda04fe801d7577b88b3fd54d33c6d0ff9a75da477a9bca21ab3b60c65b8c0e83477f0c9c4b68649cc753aac3b5b6ec0f4524982c1e278975ac
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-10-16
6
+
7
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Catenary
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # Catenary
2
+
3
+ Selective, translation-validated lowered-Ruby generator for Rails 8.1+ apps on Ruby 4.0+.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ gem install catenary
9
+ ```
10
+
11
+ ```bash
12
+ bundle exec ruby exe/catenary check fixtures/blog
13
+ bundle exec ruby exe/catenary build fixtures/blog --out tmp/blog_out
14
+ bundle exec ruby exe/catenary validate tmp/blog_out
15
+ bundle exec ruby exe/catenary bench tmp/blog_out
16
+ ```
17
+
18
+ - One target: lowered Ruby on CRuby. Rails retained as runtime library in v1.
19
+ - Acceptance is Doctor Yellow (`validation_report.json`), not differential comparison (dev-only, `corpus/run_differential.rb`).
20
+ - Platform floor is absolute: Ruby >= 4.0, Rails >= 8.1; below-floor fails `unsupported_platform`.
21
+ - Snapshot schema v1: `lib/catenary/snapshot/schema.rb`; Rubydex cross-check (runtime wins, disagreement demotes only): `lib/catenary/static/rubydex_graph.rb`; independent validator (no lowering code): `lib/catenary/validation/doctor_yellow.rb`.
22
+ - Factory/ownership: `FACTORY.md`. Blog fixture accounting/bench: `fixtures/blog/ACCOUNTING.md`, `fixtures/blog/BENCH.md`. Lobsters: `corpus/lobsters/recon.md`, `corpus/lobsters/ACCOUNTING.md`.
data/exe/catenary ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "catenary/cli"
6
+ exit Catenary::CLI.new(ARGV).run
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../catenary"
6
+ require_relative "snapshot/schema"
7
+ require_relative "snapshot/classification"
8
+ require_relative "staging/boot_runner"
9
+ require_relative "static/rubydex_graph"
10
+ require_relative "report/bench"
11
+
12
+ module Catenary
13
+ class CLI
14
+ def initialize(argv)
15
+ @argv = argv.dup
16
+ end
17
+
18
+ def run
19
+ Platform.check_catenary_ruby!
20
+ cmd = @argv.shift
21
+ case cmd
22
+ when "build" then cmd_build
23
+ when "check" then cmd_check
24
+ when "validate" then cmd_validate
25
+ when "bench" then cmd_bench
26
+ when nil, "help", "--help", "-h"
27
+ puts usage
28
+ 0
29
+ else
30
+ fail_json("unknown_command", "Unknown command: #{cmd}")
31
+ end
32
+ rescue Error => e
33
+ warn JSON.generate(e.to_h)
34
+ puts JSON.generate(e.to_h)
35
+ 1
36
+ rescue StandardError => e
37
+ err = Error.new("internal_error", "#{e.class}: #{e.message}", { "backtrace" => Array(e.backtrace).first(10) })
38
+ warn JSON.generate(err.to_h)
39
+ puts JSON.generate(err.to_h)
40
+ 1
41
+ end
42
+
43
+ private
44
+
45
+ def usage
46
+ "Usage:\n catenary build <app_path> [--config catenary.yml] [--out DIR]\n catenary check <app_path> [--config catenary.yml]\n catenary validate <build_dir>\n catenary bench <build_dir>\n"
47
+ end
48
+
49
+ def parse_opts(args)
50
+ opts = {}
51
+ parser = OptionParser.new do |o|
52
+ o.on("--config PATH") { |v| opts[:config] = v }
53
+ o.on("--out DIR") { |v| opts[:out] = v }
54
+ end
55
+ rest = parser.parse(args)
56
+ [rest, opts]
57
+ end
58
+
59
+ def fail_json(code, msg)
60
+ err = Error.new(code, msg)
61
+ puts JSON.generate(err.to_h)
62
+ 1
63
+ end
64
+
65
+ # Shared pipeline: staging (double boot) + rubydex + crosscheck + inventory
66
+ def snapshot_pipeline(app_path, config)
67
+ runner = Staging::BootRunner.new(config)
68
+ snapshot = runner.run_snapshot
69
+ graph = Static::RubydexGraph.new(app_path)
70
+ crosscheck = graph.cross_check(snapshot)
71
+ # double-run crosscheck determinism
72
+ graph2 = Static::RubydexGraph.new(app_path)
73
+ crosscheck2 = graph2.cross_check(snapshot)
74
+ if Snapshot::Schema.canonical_json(crosscheck) != Snapshot::Schema.canonical_json(crosscheck2)
75
+ raise Error.new("nondeterministic_crosscheck", "crosscheck.json differs between runs")
76
+ end
77
+
78
+ snapshot["static_crosscheck"] = { "hash" => crosscheck["hash"], "entries" => crosscheck["entries"], "declaration_count" => crosscheck["declarations"].size, "constant_reference_count" => crosscheck["constant_references"].size }
79
+ items = Snapshot::Classification.build_inventory(snapshot, config: config)
80
+ snapshot["inventory"] = items.map(&:to_h)
81
+ snapshot["manifest"]["snapshot_id"] = nil # placeholder removed below
82
+ snapshot["manifest"].delete("snapshot_id")
83
+ sid = Snapshot::Schema.snapshot_id(snapshot)
84
+ snapshot["manifest"]["snapshot_id"] = sid
85
+ [snapshot, crosscheck, items, sid, graph]
86
+ end
87
+
88
+ def cmd_check
89
+ rest, opts = parse_opts(@argv)
90
+ app_path = rest[0] or return fail_json("missing_argument", "check requires <app_path>")
91
+ config = Config.load(app_path, opts[:config])
92
+ snapshot, crosscheck, items, sid, _graph = snapshot_pipeline(app_path, config)
93
+ # strict gate on static_only / ancestor_mismatch unresolved
94
+ if config.strict
95
+ bad = crosscheck["entries"].select { |e| %w[static_only ancestor_mismatch].include?(e["type"]) }
96
+ # static_only already surfaces as reject inventory; let assert_complete raise, but give explicit code first if only ancestor
97
+ Snapshot::Classification.assert_complete!(items, strict: true) unless bad.any? { |e| e["type"] == "ancestor_mismatch" }
98
+ if bad.any? { |e| e["type"] == "ancestor_mismatch" }
99
+ raise Error.new("ancestor_mismatch", "Ancestor mismatch demotes to Retain; strict check reports entries", { "entries" => bad })
100
+ end
101
+ Snapshot::Classification.assert_complete!(items, strict: true)
102
+ else
103
+ Snapshot::Classification.assert_complete!(items, strict: false)
104
+ end
105
+ acct = Snapshot::Classification.accounting(items)
106
+ puts JSON.generate({ "snapshot_id" => sid, "accounting" => acct, "static_crosscheck_hash" => crosscheck["hash"] })
107
+ puts "Inventory: #{acct['total']} items #{acct['counts'].map { |k, v| "#{k}=#{v}" }.join(' ')} snapshot=#{sid}"
108
+ 0
109
+ end
110
+
111
+ def cmd_build
112
+ rest, opts = parse_opts(@argv)
113
+ app_path = rest[0] or return fail_json("missing_argument", "build requires <app_path>")
114
+ out = opts[:out] or return fail_json("missing_argument", "build requires --out DIR")
115
+ config = Config.load(app_path, opts[:config])
116
+ snapshot, crosscheck, items, sid, graph = snapshot_pipeline(app_path, config)
117
+ Snapshot::Classification.assert_complete!(items, strict: config.strict)
118
+
119
+ require_relative "lowering/generator"
120
+ require_relative "validation/doctor_yellow"
121
+ FileUtils.mkdir_p(out)
122
+ File.write(File.join(out, "snapshot.json"), Snapshot::Schema.canonical_json(snapshot))
123
+ File.write(File.join(out, "crosscheck.json"), Snapshot::Schema.canonical_json(crosscheck))
124
+
125
+ generator = Lowering::Generator.new(snapshot: snapshot, snapshot_id: sid, app_path: app_path, out_dir: out, config: config, rubydex_graph: graph)
126
+ generated = generator.generate(items)
127
+
128
+ validator = Validation::DoctorYellow.new(snapshot: snapshot, crosscheck: crosscheck, original_app_path: app_path, build_dir: out)
129
+ report = validator.run
130
+ demotions = []
131
+ if report["verdict"] == "fail"
132
+ # Demote failed expand/preserve items to retain once and regenerate
133
+ failed_ids = report["findings"].map { |f| f["item"] }.uniq
134
+ items.each do |item|
135
+ if failed_ids.any? { |fid| fid.to_s.include?(item.name.to_s) } && %w[expand preserve].include?(item.classification)
136
+ item.classification = "retain"
137
+ demotions << { "item" => item.id, "from" => "generated", "to" => "retain" }
138
+ end
139
+ end
140
+ snapshot["inventory"] = items.map(&:to_h)
141
+ # regenerate once
142
+ generator2 = Lowering::Generator.new(snapshot: snapshot, snapshot_id: sid, app_path: app_path, out_dir: out, config: config, rubydex_graph: graph)
143
+ generated = generator2.generate(items)
144
+ File.write(File.join(out, "snapshot.json"), Snapshot::Schema.canonical_json(snapshot))
145
+ report = Validation::DoctorYellow.new(snapshot: snapshot, crosscheck: crosscheck, original_app_path: app_path, build_dir: out).run
146
+ if report["verdict"] == "fail"
147
+ # Only fail build if failed items are still expand/preserve
148
+ still = report["findings"].select { |f| items.any? { |i| i.id == f["item"] && %w[expand preserve].include?(i.classification) } }
149
+ raise Error.new("validation_failed", "Doctor Yellow failed after one demotion cycle", { "findings" => report["findings"] }) unless still.empty?
150
+ end
151
+ end
152
+ report["demotions"] = demotions
153
+ report["generator_demotions"] = Array(generator.demotions)
154
+ File.write(File.join(out, "validation_report.json"), JSON.pretty_generate(report))
155
+ File.write(File.join(out, "build_report.json"), JSON.pretty_generate({ "snapshot_id" => sid, "generated_files" => generated, "demotions" => report["demotions"], "generator_demotions" => report["generator_demotions"], "validation" => report["verdict"], "modified_retained_config" => ["config/boot.rb (shim)"] }))
156
+ # verify generated ruby parses
157
+ generated.each do |rel|
158
+ next if rel.include?("(shim)")
159
+
160
+ path = File.join(out, rel)
161
+ next unless File.exist?(path) && path.end_with?(".rb")
162
+
163
+ out_c, err_c, st = Open3.capture3("ruby", "-c", path)
164
+ raise Error.new("generated_syntax_error", "#{rel}: #{err_c}") unless st.success?
165
+ end
166
+ puts JSON.generate({ "snapshot_id" => sid, "out" => out, "generated_files" => generated, "validation" => report["verdict"], "demotions" => report["demotions"] })
167
+ report["verdict"] == "pass" ? 0 : 1
168
+ end
169
+
170
+ def cmd_validate
171
+ rest, opts = parse_opts(@argv)
172
+ build_dir = rest[0] or return fail_json("missing_argument", "validate requires <build_dir>")
173
+ snap_path = File.join(build_dir, "snapshot.json")
174
+ cross_path = File.join(build_dir, "crosscheck.json")
175
+ return fail_json("missing_build", "snapshot.json not found in #{build_dir}") unless File.exist?(snap_path)
176
+
177
+ snapshot = JSON.parse(File.read(snap_path))
178
+ cross = File.exist?(cross_path) ? JSON.parse(File.read(cross_path)) : {}
179
+ original = snapshot.dig("manifest", "app_path") || build_dir
180
+ require_relative "validation/doctor_yellow"
181
+ report = Validation::DoctorYellow.new(snapshot: snapshot, crosscheck: cross, original_app_path: original, build_dir: build_dir).run
182
+ File.write(File.join(build_dir, "validation_report.json"), JSON.pretty_generate(report))
183
+ puts JSON.generate(report)
184
+ report["verdict"] == "pass" ? 0 : 1
185
+ end
186
+
187
+ def cmd_bench
188
+ rest, _opts = parse_opts(@argv)
189
+ build_dir = rest[0] or return fail_json("missing_argument", "bench requires <build_dir>")
190
+ snap_path = File.join(build_dir, "snapshot.json")
191
+ original = File.exist?(snap_path) ? JSON.parse(File.read(snap_path)).dig("manifest", "app_path") : nil
192
+ result = Report::Bench.new(original_path: original, build_dir: build_dir).run
193
+ puts JSON.pretty_generate(result)
194
+ File.write(File.join(build_dir, "bench.json"), JSON.pretty_generate(result))
195
+ 0
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest"
5
+ require_relative "source_recovery"
6
+ require_relative "rules/base"
7
+ require_relative "rules/v1"
8
+
9
+ module Catenary
10
+ module Lowering
11
+ class Generator
12
+ attr_reader :generated_files, :demotions
13
+
14
+ def initialize(snapshot:, snapshot_id:, app_path:, out_dir:, config:, rubydex_graph: nil)
15
+ @snapshot = snapshot
16
+ @snapshot_id = snapshot_id
17
+ @app_path = File.expand_path(app_path)
18
+ @out = out_dir
19
+ @config = config
20
+ @graph = rubydex_graph
21
+ @generated_files = []
22
+ @demotions = []
23
+ @recovery = SourceRecovery.new(@app_path, rubydex_graph: rubydex_graph)
24
+ end
25
+
26
+ def generate(inventory)
27
+ FileUtils.mkdir_p(@out)
28
+ copy_app_tree
29
+ sid = @snapshot_id
30
+ header = ->(rule, src) { "# catenary snapshot:#{sid} rule:#{rule} source:#{src}\n" }
31
+
32
+ # RouteDispatch + RouteHelpers (standalone modules, never reopen retained classes)
33
+ rd = Rules::RouteDispatch.new
34
+ rh = Rules::RouteHelpers.new
35
+ route_item = Struct.new(:kind).new("route")
36
+ emit_file("catenary_generated/routes_dispatch.rb", header.call("RouteDispatch", "snapshot:routes") + rd.emit(route_item, @snapshot))
37
+ emit_file("catenary_generated/route_helpers.rb", header.call("RouteHelpers", "snapshot:routes") + rh.emit(route_item, @snapshot))
38
+
39
+ # Templates
40
+ # Templates: only emit when compiled ERB parses; block-form templates demote to Retain
41
+ require "prism"
42
+ erb_rule = Rules::ErbTemplate.new(app_path: @app_path)
43
+ inventory.select { |i| i.kind == "template" && i.classification == "expand" }.each do |item|
44
+ out_rel = File.join("catenary_generated/templates", File.basename(item.name.gsub("/", "_")) + ".rb")
45
+ content = header.call("ErbTemplate", item.name) + erb_rule.emit(item, @snapshot)
46
+ if Prism.parse(content).success?
47
+ emit_file(out_rel, content)
48
+ else
49
+ @demotions << { "item" => item.id, "reason" => "erb_compile_unparseable_block_form" }
50
+ end
51
+ end
52
+
53
+ # Per-owner file-level partition (file-scoped): replacement uses only methods defined in the owner's app file.
54
+ by_owner = inventory.select { |i| i.kind == "method" }.group_by(&:owner)
55
+ preserve_rule = Rules::PreserveMethod.new(recovery: @recovery)
56
+ emitted_rel = {}
57
+ file_of = lambda do |owner|
58
+ klass = Array(@snapshot["classes"]).find { |c| c["name"] == owner }
59
+ next nil unless klass
60
+
61
+ m = Array(klass["methods"]).find { |mm| mm["app_defined"] && mm["source_location"] }
62
+ m && m["source_location"][0]
63
+ end
64
+ loc_of = ->(item) { (item.extra || {})["source_location"]&.first }
65
+ by_owner.each do |owner, items|
66
+ next if owner.nil?
67
+
68
+ file = file_of.call(owner)
69
+ next if file.nil? || file.start_with?("app/models/")
70
+
71
+ file_methods = items.select { |i| loc_of.call(i) == file }
72
+ other_retained = items.count { |i| !file_methods.include?(i) && i.classification == "retain" }
73
+ @demotions << { "item" => "class:#{owner}", "kind" => "info", "reason" => "framework_owned_methods_retained_not_in_file:#{other_retained}" } if other_retained.positive?
74
+ next if file_methods.empty?
75
+ next if file_methods.any? { |i| i.classification == "retain" || i.classification == "reject" }
76
+ next unless file.start_with?("app/")
77
+
78
+ failing = file_methods.find { |item| !preserve_rule.applies?(item, @snapshot) }
79
+ if failing
80
+ @demotions << { "item" => failing.id, "reason" => preserve_rule.preconditions(failing, @snapshot).join("; ") }
81
+ next
82
+ end
83
+ next if emitted_rel[file]
84
+
85
+ orig_path = File.join(@app_path, file)
86
+ next unless File.exist?(orig_path)
87
+
88
+ content = header.call("PreserveMethod", file) + File.read(orig_path)
89
+ content += "\n" unless content.end_with?("\n")
90
+ if Prism.parse(content).success?
91
+ emit_file(file, content)
92
+ emitted_rel[file] = true
93
+ else
94
+ @demotions << { "item" => "class:#{owner}", "reason" => "preserve_emit_unparseable" }
95
+ end
96
+ end
97
+
98
+ # ModelDefinitions: emit real model class to original app/models path
99
+ model_rule = Rules::ModelDefinitions.new
100
+ inventory.select { |i| i.kind == "model" }.each do |item|
101
+ model_entry = Array(@snapshot["models"]).find { |m| m["name"] == item.owner }
102
+ next unless model_entry && model_entry["app_defined"]
103
+
104
+ methods = by_owner[item.owner] || []
105
+ file = file_of.call(item.owner)
106
+ model_file = if file && file.start_with?("app/models/")
107
+ file
108
+ else
109
+ derived = "app/models/#{item.owner.to_s.split('::').map { |s| s.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2').gsub(/([a-z0-9])([A-Z])/, '\\1_\\2').downcase }.join('/')}.rb"
110
+ File.exist?(File.join(@app_path, derived)) ? derived : file
111
+ end
112
+ next unless model_file && model_file.start_with?("app/models/")
113
+
114
+ file_methods = methods.select { |i| loc_of.call(i) == model_file }
115
+ next if file_methods.any? { |i| i.classification == "retain" || i.classification == "reject" }
116
+
117
+ failing = file_methods.select { |i| i.classification == "preserve" }.find { |i| !preserve_rule.applies?(i, @snapshot) }
118
+ if failing
119
+ @demotions << { "item" => failing.id, "reason" => preserve_rule.preconditions(failing, @snapshot).join("; ") }
120
+ next
121
+ end
122
+ if (reason = model_rule.ineligible_reason(item, @snapshot))
123
+ @demotions << { "item" => item.id, "reason" => reason }
124
+ next
125
+ end
126
+ next if emitted_rel[model_file]
127
+
128
+ content = header.call("ModelDefinitions", "snapshot:models") + model_rule.emit(item, @snapshot)
129
+ if Prism.parse(content).success?
130
+ emit_file(model_file, content)
131
+ emitted_rel[model_file] = true
132
+ else
133
+ @demotions << { "item" => item.id, "reason" => "model_emit_unparseable" }
134
+ end
135
+ end
136
+
137
+ # Runtime support + boot shim (only retained config modification, listed in report)
138
+ runtime_src = File.expand_path("../../../runtime/catenary_runtime.rb", __dir__)
139
+ if File.exist?(runtime_src)
140
+ FileUtils.mkdir_p(File.join(@out, "catenary_generated"))
141
+ FileUtils.cp(runtime_src, File.join(@out, "catenary_generated/catenary_runtime.rb"))
142
+ @generated_files << "catenary_generated/catenary_runtime.rb"
143
+ end
144
+ write_boot_shim(header)
145
+ @generated_files
146
+ end
147
+
148
+ private
149
+
150
+ def emit_file(rel, content)
151
+ path = File.join(@out, rel)
152
+ FileUtils.mkdir_p(File.dirname(path))
153
+ File.write(path, content)
154
+ @generated_files << rel
155
+ end
156
+
157
+ def copy_app_tree
158
+ FileUtils.mkdir_p(@out)
159
+ Dir[File.join(@app_path, "**/*")].each do |src|
160
+ rel = src.sub("#{@app_path}/", "")
161
+ next if rel.start_with?(".git", "tmp/", "log/", "node_modules/", "vendor/bundle")
162
+ next if rel == ".git"
163
+
164
+ dest = File.join(@out, rel)
165
+ if File.directory?(src)
166
+ FileUtils.mkdir_p(dest)
167
+ else
168
+ FileUtils.mkdir_p(File.dirname(dest))
169
+ FileUtils.cp(src, dest)
170
+ end
171
+ end
172
+ end
173
+
174
+ def write_boot_shim(header)
175
+ boot = File.join(@out, "config/boot.rb")
176
+ return unless File.exist?(boot)
177
+
178
+ original = File.read(boot)
179
+ shim = header.call("RetainedRuntime", "config/boot.rb") + original + "\nENV[\"CATENARY_GENERATED\"] = \"1\"\nbegin; require_relative \"../catenary_generated/catenary_runtime\"; rescue LoadError; end\n"
180
+ File.write(boot, shim)
181
+ @generated_files << "config/boot.rb (shim)"
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catenary
4
+ module Lowering
5
+ module Rules
6
+ class Base
7
+ def applies?(_item, _snapshot) = false
8
+ def preconditions(_item, _snapshot) = []
9
+ def emit(_item, _snapshot) = raise NotImplementedError
10
+ def self.rule_name = name.split("::").last
11
+ end
12
+ end
13
+ end
14
+ end