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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +22 -0
- data/exe/catenary +6 -0
- data/lib/catenary/cli.rb +198 -0
- data/lib/catenary/lowering/generator.rb +185 -0
- data/lib/catenary/lowering/rules/base.rb +14 -0
- data/lib/catenary/lowering/rules/v1.rb +334 -0
- data/lib/catenary/lowering/source_recovery.rb +80 -0
- data/lib/catenary/report/bench.rb +86 -0
- data/lib/catenary/snapshot/classification.rb +125 -0
- data/lib/catenary/snapshot/schema.rb +65 -0
- data/lib/catenary/staging/boot_runner.rb +100 -0
- data/lib/catenary/staging/bootstrap.rb +316 -0
- data/lib/catenary/static/rubydex_graph.rb +190 -0
- data/lib/catenary/validation/doctor_yellow.rb +199 -0
- data/lib/catenary/version.rb +5 -0
- data/lib/catenary.rb +89 -0
- data/runtime/catenary_runtime.rb +17 -0
- metadata +72 -0
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
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
data/lib/catenary/cli.rb
ADDED
|
@@ -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
|