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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "prism"
|
|
6
|
+
require_relative "../snapshot/schema"
|
|
7
|
+
|
|
8
|
+
module Catenary
|
|
9
|
+
module Validation
|
|
10
|
+
# Doctor Yellow: independent validator. MUST NOT require lowering code.
|
|
11
|
+
class DoctorYellow
|
|
12
|
+
def initialize(snapshot:, crosscheck:, original_app_path:, build_dir:)
|
|
13
|
+
@snapshot = snapshot
|
|
14
|
+
@crosscheck = crosscheck
|
|
15
|
+
@app = File.expand_path(original_app_path)
|
|
16
|
+
@build = File.expand_path(build_dir)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.from_files(snapshot_path:, crosscheck_path:, original_app_path:, build_dir:)
|
|
20
|
+
new(snapshot: JSON.parse(File.read(snapshot_path)), crosscheck: crosscheck_path && File.exist?(crosscheck_path) ? JSON.parse(File.read(crosscheck_path)) : {},
|
|
21
|
+
original_app_path: original_app_path, build_dir: build_dir)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run
|
|
25
|
+
findings = []
|
|
26
|
+
items_report = []
|
|
27
|
+
sid = expected_snapshot_id
|
|
28
|
+
inventory = @snapshot["inventory"] || []
|
|
29
|
+
# (e) manifest integrity + crosscheck hash
|
|
30
|
+
if @crosscheck && @crosscheck["hash"]
|
|
31
|
+
expected = @snapshot.dig("static_crosscheck", "hash") || @snapshot.dig("static_crosscheck", "report_hash")
|
|
32
|
+
findings << { "item" => "static_crosscheck", "check" => "manifest_integrity", "message" => "crosscheck hash mismatch snapshot=#{expected} crosscheck=#{@crosscheck['hash']}" } if expected && expected != @crosscheck["hash"]
|
|
33
|
+
end
|
|
34
|
+
generated_files = Dir[File.join(@build, "catenary_generated/**/*.rb")].sort
|
|
35
|
+
# Index generated headers
|
|
36
|
+
header_sid = {}
|
|
37
|
+
generated_files.each do |f|
|
|
38
|
+
first = File.read(f).lines.first.to_s
|
|
39
|
+
if first =~ /snapshot:([0-9a-f]+)/
|
|
40
|
+
header_sid[f] = Regexp.last_match(1)
|
|
41
|
+
findings << { "item" => f, "check" => "manifest_integrity", "message" => "header snapshot #{Regexp.last_match(1)} != #{sid}" } if Regexp.last_match(1) != sid
|
|
42
|
+
end
|
|
43
|
+
# (d) parse
|
|
44
|
+
parsed = Prism.parse(File.read(f))
|
|
45
|
+
findings << { "item" => f, "check" => "parse", "message" => parsed.errors.map(&:message).join("; ") } if parsed.failure?
|
|
46
|
+
end
|
|
47
|
+
# Generated Rubydex graph (independent)
|
|
48
|
+
gen_decl_names = generated_declaration_names
|
|
49
|
+
|
|
50
|
+
# (a) inventory completeness: every inventory item must be accounted in generated/retained/rejected
|
|
51
|
+
inventory.each do |item|
|
|
52
|
+
id = item["id"] || item[:id]
|
|
53
|
+
classification = item["classification"]
|
|
54
|
+
unless %w[expand preserve retain reject].include?(classification)
|
|
55
|
+
findings << { "item" => id, "check" => "inventory_completeness", "message" => "unclassified" }
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
items_report << { "id" => id, "classification" => classification, "reason" => item["reason"] }
|
|
59
|
+
# (b) preserve equivalence
|
|
60
|
+
check_preserve(item, findings) if classification == "preserve"
|
|
61
|
+
# (c) precondition re-check (validator's own encoding)
|
|
62
|
+
check_preconditions(item, findings) if classification == "expand"
|
|
63
|
+
end
|
|
64
|
+
# (d) generated constants resolve in generated graph or snapshot/retained
|
|
65
|
+
check_generated_constants(generated_files, gen_decl_names, findings)
|
|
66
|
+
|
|
67
|
+
verdict = findings.empty? ? "pass" : "fail"
|
|
68
|
+
{ "verdict" => verdict, "snapshot_id" => sid, "findings" => findings, "items" => items_report,
|
|
69
|
+
"generated_files" => generated_files.map { |f| f.sub("#{@build}/", "") }, "demotions" => [] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def stripped_snapshot
|
|
75
|
+
# snapshot_id computed over canonical snapshot without inventory? Generator headers use id computed after inventory+crosscheck embedded.
|
|
76
|
+
# Accept snapshot as stored: if manifest contains snapshot_id use it.
|
|
77
|
+
@snapshot
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def expected_snapshot_id
|
|
81
|
+
@snapshot.dig("manifest", "snapshot_id") || Snapshot::Schema.snapshot_id(@snapshot)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def generated_declaration_names
|
|
85
|
+
# Own Rubydex graph over generated tree; fall back to Prism constant scan if Rubydex unavailable (validator still independent of lowering)
|
|
86
|
+
begin
|
|
87
|
+
require_relative "../static/rubydex_graph"
|
|
88
|
+
g = Static::RubydexGraph.build_for(@build)
|
|
89
|
+
return g.declarations.map { |d| d["name"] }
|
|
90
|
+
rescue StandardError
|
|
91
|
+
names = []
|
|
92
|
+
Dir[File.join(@build, "**/*.rb")].each do |f|
|
|
93
|
+
parsed = Prism.parse(File.read(f)) rescue next
|
|
94
|
+
walk(parsed.value) { |n| names << n.full_name rescue nil if n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
|
|
95
|
+
end
|
|
96
|
+
names.compact
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def check_preserve(item, findings)
|
|
101
|
+
owner = item["owner"]
|
|
102
|
+
name = item["name"]
|
|
103
|
+
klass = Array(@snapshot["classes"]).find { |c| c["name"] == owner }
|
|
104
|
+
return findings << { "item" => item["id"], "check" => "preserve_equivalence", "message" => "owner not in snapshot" } unless klass
|
|
105
|
+
|
|
106
|
+
candidates = Array(klass["methods"]).select { |m| m["name"] == name }
|
|
107
|
+
meth = candidates.find { |m| m["source_location"] && !!m["singleton"] == !!item["singleton"] } || candidates.find { |m| m["source_location"] }
|
|
108
|
+
return findings << { "item" => item["id"], "check" => "preserve_equivalence", "message" => "method not in snapshot" } unless meth && meth["source_location"]
|
|
109
|
+
|
|
110
|
+
orig_file = File.join(@app, meth["source_location"][0])
|
|
111
|
+
return unless File.exist?(orig_file)
|
|
112
|
+
|
|
113
|
+
orig_node = find_def(orig_file, name, meth["source_location"][1])
|
|
114
|
+
return findings << { "item" => item["id"], "check" => "preserve_equivalence", "message" => "original def not found" } unless orig_node
|
|
115
|
+
|
|
116
|
+
# Generated counterpart is the replaced original file in the build tree
|
|
117
|
+
gen_file = File.join(@build, meth["source_location"][0])
|
|
118
|
+
return unless File.exist?(gen_file) # class partitioned to retain: no generated file to compare
|
|
119
|
+
|
|
120
|
+
if File.exist?(gen_file)
|
|
121
|
+
gen_node = find_def(gen_file, name, nil)
|
|
122
|
+
if gen_node
|
|
123
|
+
o = canonical_ast(orig_node)
|
|
124
|
+
g = canonical_ast(gen_node)
|
|
125
|
+
findings << { "item" => item["id"], "check" => "preserve_equivalence", "message" => "AST divergence original=#{o[0..200]} generated=#{g[0..200]}" } if o != g
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
# (b cont) constant substitution re-resolution via own Rubydex on original
|
|
129
|
+
begin
|
|
130
|
+
require_relative "../static/rubydex_graph"
|
|
131
|
+
@orig_graph ||= Static::RubydexGraph.build_for(@app)
|
|
132
|
+
# If generated body contains fully-qualified constants, ensure rubydex resolves same - checked via crosscheck entries presence
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
findings << { "item" => item["id"], "check" => "preserve_equivalence", "message" => "rubydex re-resolution failed: #{e.message}" }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def check_preconditions(item, findings)
|
|
139
|
+
case item["kind"]
|
|
140
|
+
when "route"
|
|
141
|
+
r = Array(@snapshot["routes"]).find { |x| item["id"].include?("#{x['controller']}##{x['action']}") }
|
|
142
|
+
findings << { "item" => item["id"], "check" => "precondition", "message" => "route not in snapshot" } unless r
|
|
143
|
+
when "model", "reflection", "validator", "callback"
|
|
144
|
+
m = Array(@snapshot["models"]).find { |x| x["name"] == item["owner"] }
|
|
145
|
+
findings << { "item" => item["id"], "check" => "precondition", "message" => "model not in snapshot" } unless m
|
|
146
|
+
when "template"
|
|
147
|
+
t = Array(@snapshot["templates"]).find { |x| x["path"] == item["name"] }
|
|
148
|
+
findings << { "item" => item["id"], "check" => "precondition", "message" => "template handler not erb" } if t && t["handler"] != "erb"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def check_generated_constants(generated_files, gen_decl_names, findings)
|
|
153
|
+
snapshot_names = Array(@snapshot["classes"]).map { |c| c["name"] }
|
|
154
|
+
allowed = (snapshot_names + gen_decl_names + %w[CatenaryGenerated ActiveRecord ERB]).uniq
|
|
155
|
+
generated_files.each do |f|
|
|
156
|
+
parsed = Prism.parse(File.read(f))
|
|
157
|
+
walk(parsed.value) do |n|
|
|
158
|
+
next unless n.is_a?(Prism::ConstantReadNode)
|
|
159
|
+
|
|
160
|
+
name = n.name.to_s
|
|
161
|
+
# Only check top-level generated constants
|
|
162
|
+
next unless %w[CatenaryGenerated].include?(name)
|
|
163
|
+
findings << { "item" => f, "check" => "generated_constants", "message" => "unresolved constant #{name}" } unless allowed.include?(name)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def find_def(file, name, line)
|
|
169
|
+
parsed = Prism.parse(File.read(file))
|
|
170
|
+
found = nil
|
|
171
|
+
walk(parsed.value) do |n|
|
|
172
|
+
next unless n.is_a?(Prism::DefNode) && n.name.to_s == name
|
|
173
|
+
|
|
174
|
+
found = n if line.nil? || n.location.start_line == line || found.nil?
|
|
175
|
+
found = n if line && n.location.start_line == line
|
|
176
|
+
end
|
|
177
|
+
found
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def canonical_ast(node)
|
|
181
|
+
# Canonical: node class + name + body structure ignoring locations
|
|
182
|
+
return "" unless node
|
|
183
|
+
|
|
184
|
+
parts = []
|
|
185
|
+
walk(node) { |n| parts << [n.class.to_s, (n.name.to_s rescue ""), (n.slice.gsub(/\s+/, " ")[0..80] rescue "")].join(":") if n.is_a?(Prism::DefNode) }
|
|
186
|
+
# Stronger: compare def body slice normalized (substitutions are only send/const - body slice equality catches literal changes)
|
|
187
|
+
body = node.body ? node.body.slice.gsub(/\s+/, " ").strip : ""
|
|
188
|
+
"#{node.name}:#{body}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def walk(node, &block)
|
|
192
|
+
return unless node.is_a?(Prism::Node)
|
|
193
|
+
|
|
194
|
+
yield node
|
|
195
|
+
node.compact_child_nodes.each { |c| walk(c, &block) }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
data/lib/catenary.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "catenary/version"
|
|
4
|
+
|
|
5
|
+
require "json"
|
|
6
|
+
require "digest"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "yaml"
|
|
9
|
+
|
|
10
|
+
module Catenary
|
|
11
|
+
SNAPSHOT_SCHEMA_VERSION = 2
|
|
12
|
+
|
|
13
|
+
class Error < StandardError
|
|
14
|
+
attr_reader :code, :details
|
|
15
|
+
|
|
16
|
+
def initialize(code, message, details = {})
|
|
17
|
+
super(message)
|
|
18
|
+
@code = code
|
|
19
|
+
@details = details
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_h
|
|
23
|
+
{ "error" => { "code" => code, "message" => message, "details" => details } }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module Platform
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def check_catenary_ruby!
|
|
31
|
+
return if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("4.0")
|
|
32
|
+
|
|
33
|
+
raise Error.new("unsupported_platform", "Catenary requires Ruby >= 4.0, detected #{RUBY_VERSION}",
|
|
34
|
+
{ "ruby" => RUBY_VERSION, "required_ruby" => ">= 4.0" })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Called inside staging subprocess against target app versions.
|
|
38
|
+
def check_target!(ruby_version:, rails_version:)
|
|
39
|
+
ok_ruby = Gem::Version.new(ruby_version) >= Gem::Version.new("4.0")
|
|
40
|
+
ok_rails = false
|
|
41
|
+
if rails_version
|
|
42
|
+
v = Gem::Version.new(rails_version)
|
|
43
|
+
ok_rails = v.segments[0] == 8 && v.segments[1] >= 1
|
|
44
|
+
end
|
|
45
|
+
return if ok_ruby && ok_rails
|
|
46
|
+
|
|
47
|
+
raise Error.new("unsupported_platform",
|
|
48
|
+
"Target requires Ruby >= 4.0 and Rails >= 8.1, detected Ruby #{ruby_version} Rails #{rails_version || 'unknown'}",
|
|
49
|
+
{ "ruby" => ruby_version, "rails" => rails_version, "required_ruby" => ">= 4.0", "required_rails" => ">= 8.1" })
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class Config
|
|
54
|
+
attr_reader :app_path, :rails_env, :env_allowlist, :secrets, :retain, :strict, :raw
|
|
55
|
+
|
|
56
|
+
def initialize(app_path:, rails_env: "production", env_allowlist: [], secrets: {}, retain: [], strict: true, raw: {})
|
|
57
|
+
@app_path = app_path
|
|
58
|
+
@rails_env = rails_env
|
|
59
|
+
@env_allowlist = env_allowlist
|
|
60
|
+
@secrets = secrets
|
|
61
|
+
@retain = retain
|
|
62
|
+
@strict = strict
|
|
63
|
+
@raw = raw
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.load(app_path, config_path = nil)
|
|
67
|
+
path = config_path || (File.exist?(File.join(app_path, "catenary.yml")) ? File.join(app_path, "catenary.yml") : nil)
|
|
68
|
+
data = {}
|
|
69
|
+
if path
|
|
70
|
+
raise Error.new("config_not_found", "Config not found: #{path}") unless File.exist?(path)
|
|
71
|
+
|
|
72
|
+
data = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true) || {}
|
|
73
|
+
end
|
|
74
|
+
new(
|
|
75
|
+
app_path: data["app_path"] || app_path,
|
|
76
|
+
rails_env: data["rails_env"] || "production",
|
|
77
|
+
env_allowlist: Array(data["env_allowlist"]).map(&:to_s),
|
|
78
|
+
secrets: data["secrets"] || {},
|
|
79
|
+
retain: Array(data["retain"]).map(&:to_s),
|
|
80
|
+
strict: data.key?("strict") ? !!data["strict"] : true,
|
|
81
|
+
raw: data
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def forced_retain?(class_name)
|
|
86
|
+
retain.any? { |glob| File.fnmatch(glob, class_name) }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Thin runtime copied into generated apps under catenary_generated/.
|
|
4
|
+
# Establishes runtime-only state by letting the original boot path run; this file only adds fallthrough glue.
|
|
5
|
+
module CatenaryRuntime
|
|
6
|
+
def self.generated?
|
|
7
|
+
ENV["CATENARY_GENERATED"] == "1"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Fallthrough glue: generated dispatch calls retained router when present.
|
|
11
|
+
def self.retained_router(app = nil)
|
|
12
|
+
app ||= (defined?(Rails) && Rails.application) if defined?(Rails)
|
|
13
|
+
return nil unless app
|
|
14
|
+
|
|
15
|
+
->(_method, _path) { :fallthrough_to_rails }
|
|
16
|
+
end
|
|
17
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: catenary
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Catenary
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rubydex
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.4.1
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.4.1
|
|
26
|
+
description: Catenary generates selective, translation-validated lowered Ruby for
|
|
27
|
+
Rails 8.1+ applications running on Ruby 4.0+.
|
|
28
|
+
executables:
|
|
29
|
+
- catenary
|
|
30
|
+
extensions: []
|
|
31
|
+
extra_rdoc_files: []
|
|
32
|
+
files:
|
|
33
|
+
- CHANGELOG.md
|
|
34
|
+
- LICENSE.txt
|
|
35
|
+
- README.md
|
|
36
|
+
- exe/catenary
|
|
37
|
+
- lib/catenary.rb
|
|
38
|
+
- lib/catenary/cli.rb
|
|
39
|
+
- lib/catenary/lowering/generator.rb
|
|
40
|
+
- lib/catenary/lowering/rules/base.rb
|
|
41
|
+
- lib/catenary/lowering/rules/v1.rb
|
|
42
|
+
- lib/catenary/lowering/source_recovery.rb
|
|
43
|
+
- lib/catenary/report/bench.rb
|
|
44
|
+
- lib/catenary/snapshot/classification.rb
|
|
45
|
+
- lib/catenary/snapshot/schema.rb
|
|
46
|
+
- lib/catenary/staging/boot_runner.rb
|
|
47
|
+
- lib/catenary/staging/bootstrap.rb
|
|
48
|
+
- lib/catenary/static/rubydex_graph.rb
|
|
49
|
+
- lib/catenary/validation/doctor_yellow.rb
|
|
50
|
+
- lib/catenary/version.rb
|
|
51
|
+
- runtime/catenary_runtime.rb
|
|
52
|
+
licenses:
|
|
53
|
+
- MIT
|
|
54
|
+
metadata: {}
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '4.0'
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 4.0.20
|
|
70
|
+
specification_version: 4
|
|
71
|
+
summary: Selective, translation-validated lowered-Ruby generator for Rails apps
|
|
72
|
+
test_files: []
|