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,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Catenary
|
|
7
|
+
module Snapshot
|
|
8
|
+
module Schema
|
|
9
|
+
TOP_LEVEL_KEYS = %w[manifest routes models classes templates mutations static_crosscheck inventory].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Canonicalize: sort object keys recursively, sort arrays by natural key where determinable.
|
|
14
|
+
def canonicalize(obj)
|
|
15
|
+
case obj
|
|
16
|
+
when Hash
|
|
17
|
+
obj.keys.sort.each_with_object({}) { |k, h| h[k.to_s] = canonicalize(obj[k]) }
|
|
18
|
+
when Array
|
|
19
|
+
arr = obj.map { |e| canonicalize(e) }
|
|
20
|
+
# Only record collections have a natural key; tuples (source_location, parameters) and
|
|
21
|
+
# ordered lists (ancestors) must retain their semantic order.
|
|
22
|
+
arr.all? { |e| e.is_a?(Hash) } && arr.size > 1 ? arr.sort_by { |e| natural_key(e) } : arr
|
|
23
|
+
else
|
|
24
|
+
normalize_scalar(obj)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def natural_key(e)
|
|
29
|
+
return e.to_s unless e.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
[e["name"] || e["path"] || "", e["verb"] || "", e["controller"] || "", e["action"] || "",
|
|
32
|
+
e["kind"] || "", e["table_name"] || "", e["id"] || "", JSON.generate(e)]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalize_scalar(v)
|
|
36
|
+
v.is_a?(Symbol) ? v.to_s : v
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def canonical_json(doc)
|
|
40
|
+
JSON.generate(canonicalize(doc))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def snapshot_id(doc)
|
|
44
|
+
Digest::SHA256.hexdigest(canonical_json(doc))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate!(doc)
|
|
48
|
+
missing = TOP_LEVEL_KEYS - doc.keys.map(&:to_s)
|
|
49
|
+
raise Error.new("invalid_snapshot", "Snapshot missing keys: #{missing.join(', ')}", { "missing" => missing }) unless missing.empty?
|
|
50
|
+
raise Error.new("invalid_snapshot", "Schema version mismatch") unless doc.dig("manifest", "schema_version") == SNAPSHOT_SCHEMA_VERSION
|
|
51
|
+
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def empty_document(manifest:, routes: [], models: [], classes: [], templates: [], mutations: [], static_crosscheck: {}, inventory: [])
|
|
56
|
+
{
|
|
57
|
+
"manifest" => manifest.merge("schema_version" => SNAPSHOT_SCHEMA_VERSION),
|
|
58
|
+
"routes" => routes, "models" => models, "classes" => classes,
|
|
59
|
+
"templates" => templates, "mutations" => mutations,
|
|
60
|
+
"static_crosscheck" => static_crosscheck, "inventory" => inventory
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require_relative "../snapshot/schema"
|
|
7
|
+
|
|
8
|
+
module Catenary
|
|
9
|
+
module Staging
|
|
10
|
+
class BootRunner
|
|
11
|
+
BOOTSTRAP = File.expand_path("bootstrap.rb", __dir__)
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Runs bootstrap twice in fresh subprocesses; canonical-diff is the nondeterminism gate.
|
|
18
|
+
def run_snapshot
|
|
19
|
+
first = run_once
|
|
20
|
+
second = run_once
|
|
21
|
+
c1 = Snapshot::Schema.canonical_json(first)
|
|
22
|
+
c2 = Snapshot::Schema.canonical_json(second)
|
|
23
|
+
if c1 != c2
|
|
24
|
+
diff = diff_summary(first, second)
|
|
25
|
+
raise Error.new("nondeterministic_snapshot", "Double-boot snapshots differ: #{diff.join('; ')}", { "diff" => diff })
|
|
26
|
+
end
|
|
27
|
+
first
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run_once
|
|
31
|
+
Dir.mktmpdir("catenary-stage") do |dir|
|
|
32
|
+
out = File.join(dir, "snapshot.json")
|
|
33
|
+
env = scrubbed_env
|
|
34
|
+
cmd = staging_command(out)
|
|
35
|
+
stdout, stderr, status = Open3.capture3(env, *cmd, chdir: @config.app_path)
|
|
36
|
+
unless status.success?
|
|
37
|
+
parsed = parse_error(stderr) || parse_error(stdout)
|
|
38
|
+
raise(parsed || Error.new("staging_boot_failed", "Staging boot failed (exit #{status.exitstatus}): #{stderr.lines.first(5).join}", { "stderr" => stderr, "stdout" => stdout }))
|
|
39
|
+
end
|
|
40
|
+
raise Error.new("staging_boot_failed", "Bootstrap produced no snapshot") unless File.exist?(out)
|
|
41
|
+
|
|
42
|
+
doc = JSON.parse(File.read(out))
|
|
43
|
+
doc["manifest"]["env_allowlist"] = @config.env_allowlist.sort
|
|
44
|
+
doc["mutations"] = Array(doc["mutations"]).map { |m| m.transform_values { |v| v.is_a?(String) ? v.gsub(/0x[0-9a-f]{6,}/i, "0xADDR") : v } }.uniq { |m| JSON.generate(m) }.sort_by { |m| JSON.generate(m) }
|
|
45
|
+
doc
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def staging_command(out)
|
|
52
|
+
args = [BOOTSTRAP, File.expand_path(@config.app_path), out]
|
|
53
|
+
if File.exist?(File.join(@config.app_path, "Gemfile"))
|
|
54
|
+
["bundle", "exec", RbConfig.ruby, *args]
|
|
55
|
+
else
|
|
56
|
+
[RbConfig.ruby, *args]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def scrubbed_env
|
|
61
|
+
env = {}
|
|
62
|
+
# Scrub inherited env: only allowlist + required staging keys + minimal process essentials.
|
|
63
|
+
(@config.env_allowlist).each { |k| env[k] = ENV[k] if ENV.key?(k) }
|
|
64
|
+
@config.secrets.each { |k, v| env[k.to_s] = v.to_s }
|
|
65
|
+
env["RAILS_ENV"] = @config.rails_env
|
|
66
|
+
env["CATENARY_STAGING"] = "1"
|
|
67
|
+
env["PATH"] = [File.dirname(RbConfig.ruby), ENV["PATH"]].compact.join(":")
|
|
68
|
+
env["HOME"] = ENV["HOME"]
|
|
69
|
+
env["LANG"] = ENV["LANG"] || "C.UTF-8"
|
|
70
|
+
# Explicitly unset common secret-bearing vars not allowlisted
|
|
71
|
+
%w[DATABASE_URL SECRET_KEY_BASE AWS_SECRET_ACCESS_KEY GITHUB_TOKEN].each do |k|
|
|
72
|
+
env[k] = nil unless @config.env_allowlist.include?(k) || @config.secrets.key?(k)
|
|
73
|
+
end
|
|
74
|
+
ENV.each_key { |k| env[k] = nil if k.match?(/\A(BUNDLE_|BUNDLER_|GEM_|RUBYGEMS_)/) }
|
|
75
|
+
%w[BUNDLE_GEMFILE BUNDLE_PATH RUBYOPT RUBYLIB BUNDLER_VERSION GEM_HOME GEM_PATH RUBYGEMS_GEMDEPS BUNDLE_BIN_PATH BUNDLE_LOCKFILE].each { |k| env[k] = nil }
|
|
76
|
+
env
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_error(text)
|
|
80
|
+
text.each_line do |line|
|
|
81
|
+
j = JSON.parse(line) rescue next
|
|
82
|
+
next unless j["error"]
|
|
83
|
+
|
|
84
|
+
return Error.new(j["error"]["code"] || "staging_boot_failed", j["error"]["message"] || line, j["error"]["details"] || {})
|
|
85
|
+
end
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def diff_summary(a, b)
|
|
90
|
+
diffs = []
|
|
91
|
+
%w[routes models classes templates mutations].each do |k|
|
|
92
|
+
diffs << "#{k} differ" if Snapshot::Schema.canonical_json(a[k]) != Snapshot::Schema.canonical_json(b[k])
|
|
93
|
+
end
|
|
94
|
+
diffs << "manifest differs" if Snapshot::Schema.canonical_json(a["manifest"].except("git_sha")) != Snapshot::Schema.canonical_json(b["manifest"].except("git_sha"))
|
|
95
|
+
diffs = ["snapshot differs"] if diffs.empty?
|
|
96
|
+
diffs
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Runs INSIDE the target app subprocess. Must use only Rails 8.1+ / Ruby 4.0+ APIs.
|
|
4
|
+
# Args: app_path snapshot_out_path
|
|
5
|
+
require "json"
|
|
6
|
+
require "digest"
|
|
7
|
+
|
|
8
|
+
app_path = File.expand_path(ARGV[0])
|
|
9
|
+
out_path = ARGV[1]
|
|
10
|
+
mutations = []
|
|
11
|
+
$catenary_mutations = mutations
|
|
12
|
+
|
|
13
|
+
def rel(path, root)
|
|
14
|
+
return nil if path.nil?
|
|
15
|
+
|
|
16
|
+
p = File.expand_path(path)
|
|
17
|
+
p.start_with?(root) ? p.sub("#{root}/", "") : p
|
|
18
|
+
rescue StandardError
|
|
19
|
+
path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def safe_json(v, depth = 0)
|
|
23
|
+
return nil if depth > 3
|
|
24
|
+
|
|
25
|
+
case v
|
|
26
|
+
when String, Integer, Float, true, false, nil then v
|
|
27
|
+
when Symbol then v.to_s
|
|
28
|
+
when Array then v.map { |e| safe_json(e, depth + 1) }
|
|
29
|
+
when Hash then v.each_with_object({}) { |(k, val), h| h[k.to_s] = safe_json(val, depth + 1) }
|
|
30
|
+
when Class, Module then v.name
|
|
31
|
+
when Proc, Method
|
|
32
|
+
loc = v.source_location rescue nil
|
|
33
|
+
loc ? "proc:#{loc[0]}:#{loc[1]}" : v.class.to_s
|
|
34
|
+
else
|
|
35
|
+
s = v.to_s
|
|
36
|
+
s.match?(/0x[0-9a-f]{6,}/i) ? v.class.to_s : s
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError
|
|
39
|
+
v.class.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
# Platform floor inside subprocess
|
|
44
|
+
unless Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("4.0")
|
|
45
|
+
warn JSON.generate({ "error" => { "code" => "unsupported_platform", "message" => "Ruby #{RUBY_VERSION} < 4.0" } })
|
|
46
|
+
exit 3
|
|
47
|
+
end
|
|
48
|
+
ENV["RAILS_ENV"] ||= "production"
|
|
49
|
+
require File.join(app_path, "config/environment")
|
|
50
|
+
|
|
51
|
+
rails_v = defined?(Rails) && Rails.respond_to?(:version) ? Rails.version : nil
|
|
52
|
+
rails_v ||= defined?(Rails::VERSION) ? Rails::VERSION::STRING : nil
|
|
53
|
+
ok_rails = false
|
|
54
|
+
if rails_v
|
|
55
|
+
seg = Gem::Version.new(rails_v).segments
|
|
56
|
+
ok_rails = seg[0] == 8 && seg[1] >= 1
|
|
57
|
+
end
|
|
58
|
+
unless ok_rails
|
|
59
|
+
warn JSON.generate({ "error" => { "code" => "unsupported_platform", "message" => "Rails #{rails_v} < 8.1 or not 8.x", "details" => { "ruby" => RUBY_VERSION, "rails" => rails_v } } })
|
|
60
|
+
exit 3
|
|
61
|
+
end
|
|
62
|
+
unless Rails::VERSION::MAJOR == 8 && Rails::VERSION::MINOR >= 1
|
|
63
|
+
warn JSON.generate({ "error" => { "code" => "unsupported_platform", "message" => "Rails version accessor mismatch" } })
|
|
64
|
+
exit 3
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Rails.application.eager_load!
|
|
68
|
+
|
|
69
|
+
# Require Zeitwerk-unloaded files under eager load paths
|
|
70
|
+
unloadable = []
|
|
71
|
+
begin
|
|
72
|
+
paths = Rails.application.config.eager_load_paths.flat_map { |p| Dir[File.join(p, "**/*.rb")] }
|
|
73
|
+
paths.each do |f|
|
|
74
|
+
next if $LOADED_FEATURES.include?(File.expand_path(f))
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
require File.expand_path(f)
|
|
78
|
+
rescue Exception => e # rubocop:disable Lint/RescueExceptions
|
|
79
|
+
unloadable << { "type" => "unloadable_file", "name" => rel(f, app_path), "error" => "#{e.class}: #{e.message}", "retain_forever" => true }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
rescue Exception => e # rubocop:disable Lint/RescueExceptions
|
|
83
|
+
unloadable << { "type" => "unloadable_scan_error", "name" => e.class.to_s, "error" => e.message }
|
|
84
|
+
end
|
|
85
|
+
mutations.concat(unloadable)
|
|
86
|
+
|
|
87
|
+
# DB introspection (requires reachable migrated DB for AR apps)
|
|
88
|
+
if defined?(ActiveRecord::Base)
|
|
89
|
+
begin
|
|
90
|
+
ActiveRecord::Base.connection_pool.with_connection { |c| c.tables }
|
|
91
|
+
rescue Exception => e # rubocop:disable Lint/RescueExceptions
|
|
92
|
+
warn JSON.generate({ "error" => { "code" => "staging_db_unreachable", "message" => "#{e.class}: #{e.message}" } })
|
|
93
|
+
exit 4
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Mutation traps AFTER eager load (record only)
|
|
98
|
+
trap_mod = Module.new do
|
|
99
|
+
define_method(:method_added) do |name|
|
|
100
|
+
$catenary_mutations << { "type" => "method_added", "name" => "#{self}##{name}", "post_eager_load" => true }
|
|
101
|
+
super(name) if defined?(super)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
define_method(:singleton_method_added) do |name|
|
|
106
|
+
$catenary_mutations << { "type" => "singleton_method_added", "name" => "#{self}.#{name}", "post_eager_load" => true }
|
|
107
|
+
rescue StandardError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
define_method(:append_features) do |mod|
|
|
111
|
+
$catenary_mutations << { "type" => "append_features", "name" => "#{self} appended to #{mod}", "post_eager_load" => true }
|
|
112
|
+
super(mod)
|
|
113
|
+
end
|
|
114
|
+
define_method(:const_missing) do |name|
|
|
115
|
+
$catenary_mutations << { "type" => "const_missing", "name" => "#{self}::#{name}", "post_eager_load" => true }
|
|
116
|
+
super(name)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
Module.prepend(trap_mod)
|
|
120
|
+
|
|
121
|
+
# Routes
|
|
122
|
+
routes = []
|
|
123
|
+
Rails.application.routes.routes.each do |r|
|
|
124
|
+
defaults = r.defaults || {}
|
|
125
|
+
verb = r.verb.is_a?(Regexp) ? r.verb.source.gsub(/\\A|\\z|\(|\)|\^|\$/, "") : r.verb.to_s
|
|
126
|
+
verb = verb.empty? ? "GET" : verb
|
|
127
|
+
routes << {
|
|
128
|
+
"verb" => verb, "path" => r.path.spec.to_s, "controller" => defaults[:controller]&.to_s,
|
|
129
|
+
"action" => defaults[:action]&.to_s, "name" => r.name&.to_s,
|
|
130
|
+
"constraints" => safe_json(r.constraints || {})
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
helper_names = []
|
|
134
|
+
begin
|
|
135
|
+
helper_names = Rails.application.routes.url_helpers.instance_methods.map(&:to_s).sort
|
|
136
|
+
rescue StandardError
|
|
137
|
+
helper_names = []
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Models
|
|
141
|
+
models = []
|
|
142
|
+
model_classes = []
|
|
143
|
+
if defined?(ActiveRecord::Base)
|
|
144
|
+
model_classes = ActiveRecord::Base.descendants.sort_by(&:name)
|
|
145
|
+
model_classes.each do |m|
|
|
146
|
+
next unless m.name
|
|
147
|
+
|
|
148
|
+
cols = {}
|
|
149
|
+
begin
|
|
150
|
+
m.columns_hash.each { |k, c| cols[k] = { "sql_type" => c.sql_type, "type" => c.type.to_s, "null" => c.null, "default" => safe_json(c.default) } }
|
|
151
|
+
rescue StandardError
|
|
152
|
+
cols = {}
|
|
153
|
+
end
|
|
154
|
+
refls = []
|
|
155
|
+
begin
|
|
156
|
+
m.reflections.each do |k, ref|
|
|
157
|
+
refls << { "name" => k.to_s, "macro" => ref.macro.to_s, "klass_name" => (ref.klass.name rescue nil), "foreign_key" => ref.foreign_key.to_s, "options" => safe_json(ref.options || {}) }
|
|
158
|
+
end
|
|
159
|
+
rescue StandardError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
vals = []
|
|
163
|
+
begin
|
|
164
|
+
(m._validators || {}).each do |attr, vs|
|
|
165
|
+
Array(vs).each { |v| vals << { "attributes" => Array(attr).map(&:to_s), "kind" => v.kind.to_s, "options" => safe_json(v.options || {}) } }
|
|
166
|
+
end
|
|
167
|
+
rescue StandardError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
cbs = {}
|
|
171
|
+
begin
|
|
172
|
+
m.methods.grep(/\A_(.+)_callbacks\z/).each do |mm|
|
|
173
|
+
kind = mm.to_s.sub(/\A_/, "").sub(/_callbacks\z/, "")
|
|
174
|
+
chain = m.public_send(mm) rescue next
|
|
175
|
+
filters = Array(chain).map { |cb| f = (cb.filter rescue cb); f.is_a?(Proc) ? "proc:#{Array(f.source_location).join(':')}" : f.to_s.gsub(/0x[0-9a-f]{6,}/i, f.class.to_s) }
|
|
176
|
+
cbs[kind] = filters
|
|
177
|
+
end
|
|
178
|
+
rescue StandardError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
top_loc = (Object.const_source_location(m.name.split("::").first) rescue nil)
|
|
182
|
+
model_app_defined = !!(top_loc && top_loc[0].to_s.start_with?(app_path))
|
|
183
|
+
models << { "name" => m.name, "app_defined" => model_app_defined, "table_name" => (m.table_name rescue nil), "columns" => cols, "reflections" => refls.sort_by { |x| x["name"] }, "validators" => vals, "callbacks" => cbs }
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Classes reachable from routes/models/jobs/mailers/channels
|
|
188
|
+
seed = []
|
|
189
|
+
routes.each do |r|
|
|
190
|
+
next unless r["controller"]
|
|
191
|
+
|
|
192
|
+
const_name = "#{r['controller'].split('/').map { |s| s.split('_').map(&:capitalize).join }.join('::')}Controller"
|
|
193
|
+
begin
|
|
194
|
+
seed << Object.const_get(const_name)
|
|
195
|
+
rescue StandardError
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
seed.concat(model_classes)
|
|
200
|
+
%w[ActiveJob::Base ActionMailer::Base ActionCable::Channel::Base].each do |n|
|
|
201
|
+
begin
|
|
202
|
+
base = Object.const_get(n)
|
|
203
|
+
seed.concat(base.descendants)
|
|
204
|
+
rescue StandardError
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
# Zeitwerk-managed constants under app/ (helpers etc. reachable via controllers)
|
|
209
|
+
begin
|
|
210
|
+
Rails.autoloaders.each do |loader|
|
|
211
|
+
Dir[File.join(app_path, "app/**/*.rb")].each do |f|
|
|
212
|
+
cpath = loader.cpath_expected_at(f) rescue nil
|
|
213
|
+
next unless cpath
|
|
214
|
+
begin
|
|
215
|
+
seed << Object.const_get(cpath)
|
|
216
|
+
rescue StandardError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
rescue StandardError
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
# Secondary constants in multi-constant files (e.g. error classes above a controller)
|
|
225
|
+
begin
|
|
226
|
+
ObjectSpace.each_object(Module) do |m|
|
|
227
|
+
begin
|
|
228
|
+
next unless m.name
|
|
229
|
+
top = m.name.split("::").first
|
|
230
|
+
loc = Object.const_source_location(top) rescue nil
|
|
231
|
+
seed << m if loc && loc[0].to_s.start_with?(app_path)
|
|
232
|
+
rescue StandardError
|
|
233
|
+
next
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
rescue StandardError
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
classes_by_name = {}
|
|
240
|
+
queue = seed.compact.select { |k| k.is_a?(Module) && k.name }
|
|
241
|
+
visited = {}
|
|
242
|
+
until queue.empty?
|
|
243
|
+
klass = queue.shift
|
|
244
|
+
next if visited[klass.name]
|
|
245
|
+
|
|
246
|
+
visited[klass.name] = true
|
|
247
|
+
begin
|
|
248
|
+
klass.ancestors.each { |a| queue << a if a.is_a?(Module) && a.name && a.name !~ /\A(Active|Action|Rails|Zeitwerk)/ }
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
methods = []
|
|
253
|
+
begin
|
|
254
|
+
(klass.instance_methods + klass.private_instance_methods + klass.protected_instance_methods).uniq.each do |mn|
|
|
255
|
+
um = klass.instance_method(mn) rescue next
|
|
256
|
+
next unless um.owner == klass
|
|
257
|
+
loc = um.source_location
|
|
258
|
+
methods << { "name" => mn.to_s, "owner" => (um.owner.name rescue nil), "visibility" => (klass.private_instance_methods.include?(mn) ? "private" : klass.protected_instance_methods.include?(mn) ? "protected" : "public"), "parameters" => um.parameters.map { |t, n| [t.to_s, n&.to_s] }, "source_location" => loc ? [rel(loc[0], app_path), loc[1]] : nil, "app_defined" => !!(loc && !rel(loc[0], app_path).to_s.start_with?("/", "<")), "singleton" => false }
|
|
259
|
+
end
|
|
260
|
+
(klass.singleton_methods + klass.private_methods).uniq.each do |mn|
|
|
261
|
+
um = klass.method(mn) rescue next
|
|
262
|
+
next unless um.owner == klass.singleton_class || um.owner == klass
|
|
263
|
+
loc = um.source_location rescue nil
|
|
264
|
+
methods << { "name" => mn.to_s, "owner" => (um.owner.name rescue nil), "visibility" => "public", "parameters" => (um.parameters.map { |t, n| [t.to_s, n&.to_s] } rescue []), "source_location" => loc ? [rel(loc[0], app_path), loc[1]] : nil, "app_defined" => !!(loc && !rel(loc[0], app_path).to_s.start_with?("/", "<")), "singleton" => true }
|
|
265
|
+
end
|
|
266
|
+
rescue StandardError
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
app_defined = seed.include?(klass) || methods.any? { |m| m["source_location"] && !m["source_location"][0].to_s.start_with?("/", "<") }
|
|
270
|
+
if app_defined
|
|
271
|
+
classes_by_name[klass.name] = {
|
|
272
|
+
"name" => klass.name, "app_defined" => app_defined, "superclass" => (klass.is_a?(Class) ? klass.superclass&.name : nil),
|
|
273
|
+
"ancestors" => (klass.ancestors.map(&:name).compact rescue []),
|
|
274
|
+
"methods" => methods.sort_by { |x| [x["name"], x["singleton"].to_s] }
|
|
275
|
+
}
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
templates = []
|
|
280
|
+
begin
|
|
281
|
+
view_paths = Array(Rails.application.config.paths["app/views"].existent)
|
|
282
|
+
rescue StandardError
|
|
283
|
+
view_paths = [File.join(app_path, "app/views")]
|
|
284
|
+
end
|
|
285
|
+
view_paths.each do |vp|
|
|
286
|
+
Dir[File.join(vp, "**/*")].each do |f|
|
|
287
|
+
next if File.directory?(f)
|
|
288
|
+
|
|
289
|
+
base = File.basename(f)
|
|
290
|
+
handler = base.include?(".erb") || base.end_with?(".erb") ? "erb" : (base.split(".").last || "")
|
|
291
|
+
format = base.split(".")[1] || "html"
|
|
292
|
+
templates << { "path" => rel(f, app_path), "format" => format, "handler" => handler }
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
manifest = {
|
|
297
|
+
"app_path" => app_path, "schema_version" => 2,
|
|
298
|
+
"git_sha" => (system("git -C #{app_path} rev-parse HEAD >/dev/null 2>&1") ? `git -C #{app_path} rev-parse HEAD`.strip : nil),
|
|
299
|
+
"gemfile_lock_sha256" => (File.exist?(File.join(app_path, "Gemfile.lock")) ? Digest::SHA256.file(File.join(app_path, "Gemfile.lock")).hexdigest : nil),
|
|
300
|
+
"ruby_version" => RUBY_VERSION, "rails_version" => rails_v, "rails_env" => ENV["RAILS_ENV"],
|
|
301
|
+
"env_allowlist" => ENV.keys.grep(/\ACATENARY_/).sort, "route_helper_names" => helper_names
|
|
302
|
+
}
|
|
303
|
+
# env_allowlist names are filled by parent from config (sorted names only, never secret values); placeholder replaced there.
|
|
304
|
+
doc = {
|
|
305
|
+
"manifest" => manifest, "routes" => routes, "models" => models,
|
|
306
|
+
"classes" => classes_by_name.values.sort_by { |c| c["name"] },
|
|
307
|
+
"templates" => templates.sort_by { |t| t["path"] },
|
|
308
|
+
"mutations" => mutations, "static_crosscheck" => {}, "inventory" => []
|
|
309
|
+
}
|
|
310
|
+
File.write(out_path, JSON.generate(doc))
|
|
311
|
+
rescue SystemExit
|
|
312
|
+
raise
|
|
313
|
+
rescue Exception => e # rubocop:disable Lint/RescueExceptions
|
|
314
|
+
warn JSON.generate({ "error" => { "code" => "staging_boot_failed", "message" => "#{e.class}: #{e.message}", "details" => { "backtrace" => Array(e.backtrace).first(20) } } })
|
|
315
|
+
exit 5
|
|
316
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "../snapshot/schema"
|
|
6
|
+
|
|
7
|
+
module Catenary
|
|
8
|
+
module Static
|
|
9
|
+
# Only file allowed to require rubydex.
|
|
10
|
+
class RubydexGraph
|
|
11
|
+
attr_reader :declarations, :crosscheck
|
|
12
|
+
|
|
13
|
+
def initialize(app_path)
|
|
14
|
+
@app_path = File.expand_path(app_path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build
|
|
18
|
+
begin
|
|
19
|
+
require "rubydex"
|
|
20
|
+
rescue LoadError => e
|
|
21
|
+
raise Error.new("rubydex_unavailable", "rubydex gem required: #{e.message}")
|
|
22
|
+
end
|
|
23
|
+
files = Dir[File.join(@app_path, "**/*.{rb,rake,rbs,ru}")].reject { |f| f.include?("/vendor/") || f.include?("/node_modules/") || f.include?("/.git/") }
|
|
24
|
+
graph = Rubydex::Graph.new
|
|
25
|
+
begin
|
|
26
|
+
graph.index_all(files)
|
|
27
|
+
graph.resolve
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
raise Error.new("rubydex_index_failed", "Rubydex failed to index #{@app_path}: #{e.class}: #{e.message}")
|
|
30
|
+
end
|
|
31
|
+
@graph = graph
|
|
32
|
+
decls = extract_declarations(graph, files)
|
|
33
|
+
const_refs = extract_constant_references(graph)
|
|
34
|
+
@declarations = decls
|
|
35
|
+
@crosscheck = {
|
|
36
|
+
"declarations" => decls,
|
|
37
|
+
"constant_references" => const_refs,
|
|
38
|
+
"entries" => [], # filled by cross_check against snapshot
|
|
39
|
+
"files" => files.map { |f| relative(f) }.sort
|
|
40
|
+
}
|
|
41
|
+
@crosscheck
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Cross-check rules (a)-(d). Runtime wins; disagreement demotes only.
|
|
45
|
+
def cross_check(snapshot, lazy_files: [])
|
|
46
|
+
build unless @crosscheck
|
|
47
|
+
entries = []
|
|
48
|
+
snapshot_classes = {}
|
|
49
|
+
Array(snapshot["classes"]).each { |c| snapshot_classes[c["name"]] = c }
|
|
50
|
+
decl_by_name = {}
|
|
51
|
+
@declarations.each { |d| decl_by_name[d["name"]] = d }
|
|
52
|
+
|
|
53
|
+
# (a) static_only
|
|
54
|
+
@declarations.each do |d|
|
|
55
|
+
next unless %w[class module].include?(d["kind"])
|
|
56
|
+
next unless d["name"].match?(/\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/)
|
|
57
|
+
next if snapshot_classes.key?(d["name"])
|
|
58
|
+
next unless d["definitions"].any? { |df| df["file"].to_s.start_with?("app/") }
|
|
59
|
+
|
|
60
|
+
entries << { "type" => "static_only", "name" => d["name"], "file" => d["definitions"].first["file"], "reason" => "not_loaded_at_boot" }
|
|
61
|
+
end
|
|
62
|
+
# (b) runtime_only
|
|
63
|
+
snapshot_classes.each_key do |name|
|
|
64
|
+
entries << { "type" => "runtime_only", "name" => name, "reason" => "dynamic_or_unindexed" } unless decl_by_name.key?(name)
|
|
65
|
+
end
|
|
66
|
+
# (d) ancestor mismatch for app-defined classes: compare only ancestors declared in app files in both sources
|
|
67
|
+
app_defined = @declarations.select { |d| d["definitions"].any? { |df| df["file"].to_s.start_with?("app/", "lib/", "config/") } }.map { |d| d["name"] }
|
|
68
|
+
snapshot_classes.each do |name, klass|
|
|
69
|
+
next unless app_defined.include?(name)
|
|
70
|
+
decl = decl_by_name[name]
|
|
71
|
+
next unless decl && decl["ancestors"] && !decl["ancestors"].empty?
|
|
72
|
+
# Compare order of app-defined ancestors present in BOTH linearizations; framework modules
|
|
73
|
+
# Rubydex cannot resolve (runtime-only includes) are excluded, per runtime-wins precedence.
|
|
74
|
+
common = (klass["ancestors"] & decl["ancestors"] & app_defined)
|
|
75
|
+
app_anc_snap = klass["ancestors"].select { |a| common.include?(a) }
|
|
76
|
+
app_anc_dex = decl["ancestors"].select { |a| common.include?(a) }
|
|
77
|
+
entries << { "type" => "ancestor_mismatch", "name" => name, "reason" => "ancestor_mismatch", "snapshot" => app_anc_snap, "rubydex" => app_anc_dex } if app_anc_snap != app_anc_dex
|
|
78
|
+
end
|
|
79
|
+
# (c) is evaluated per substitution in lowering/validation via resolve_constant_name
|
|
80
|
+
result = @crosscheck.merge("entries" => entries.sort_by { |e| [e["type"], e["name"]] })
|
|
81
|
+
result["hash"] = Digest::SHA256.hexdigest(Snapshot::Schema.canonical_json(result.reject { |k, _| k == "hash" }))
|
|
82
|
+
result
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Rubydex lexical resolution: returns fully-qualified declaration name or nil.
|
|
86
|
+
def resolve_constant_name(name, nesting = [])
|
|
87
|
+
return nil unless @graph
|
|
88
|
+
|
|
89
|
+
decl = @graph.resolve_constant(name.to_s, Array(nesting).map(&:to_s)) rescue nil
|
|
90
|
+
decl&.name
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def definitions_for(name)
|
|
94
|
+
d = @declarations&.find { |x| x["name"] == name }
|
|
95
|
+
d ? d["definitions"] : []
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Build a graph over another tree (used by Doctor Yellow for generated tree)
|
|
99
|
+
def self.build_for(path)
|
|
100
|
+
g = new(path)
|
|
101
|
+
g.build
|
|
102
|
+
g
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def relative(path)
|
|
108
|
+
p = File.expand_path(path)
|
|
109
|
+
p.start_with?(@app_path) ? p.sub("#{@app_path}/", "") : p
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def loc_string(loc)
|
|
113
|
+
uri = loc.uri.to_s
|
|
114
|
+
file = uri.sub("file://", "")
|
|
115
|
+
[relative(file), loc.start_line + 1] # rubydex lines are 0-based in 0.4.1
|
|
116
|
+
rescue StandardError
|
|
117
|
+
[nil, nil]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def extract_declarations(graph, _files)
|
|
121
|
+
decls = []
|
|
122
|
+
graph.declarations.each do |d|
|
|
123
|
+
name = d.name.to_s rescue next
|
|
124
|
+
next if name.empty?
|
|
125
|
+
|
|
126
|
+
kind = case d.class.to_s
|
|
127
|
+
when /Class/ then d.is_a?(Rubydex::Module) && !d.is_a?(Rubydex::Class) ? "module" : "class"
|
|
128
|
+
when /Module/ then "module"
|
|
129
|
+
when /Method/ then "method"
|
|
130
|
+
when /Constant/ then "constant"
|
|
131
|
+
else d.class.to_s.split("::").last.downcase
|
|
132
|
+
end
|
|
133
|
+
# Normalize: Rubydex::Class < Module etc.
|
|
134
|
+
kind = "class" if d.is_a?(Rubydex::Class)
|
|
135
|
+
kind = "module" if d.is_a?(Rubydex::Module) && !d.is_a?(Rubydex::Class)
|
|
136
|
+
kind = "method" if defined?(Rubydex::Method) && d.is_a?(Rubydex::Method)
|
|
137
|
+
defs = []
|
|
138
|
+
begin
|
|
139
|
+
d.definitions.each do |df|
|
|
140
|
+
f, line = loc_string(df.location)
|
|
141
|
+
next unless f && f.start_with?(*["app/", "lib/", "config/"]) || (f && !f.include?("gems/") && !f.start_with?("/"))
|
|
142
|
+
|
|
143
|
+
defs << { "file" => f, "line" => line, "kind" => df.class.to_s }
|
|
144
|
+
end
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
next if defs.empty? && %w[class module method].include?(kind)
|
|
149
|
+
|
|
150
|
+
anc = begin
|
|
151
|
+
d.ancestors.map(&:name) if d.respond_to?(:ancestors)
|
|
152
|
+
rescue StandardError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
members = []
|
|
156
|
+
if d.respond_to?(:members)
|
|
157
|
+
begin
|
|
158
|
+
d.members.each { |m| members << { "name" => m.name.to_s, "kind" => m.class.to_s } }
|
|
159
|
+
rescue StandardError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
decls << { "name" => name, "kind" => kind, "definitions" => defs.sort_by { |x| [x["file"].to_s, x["line"].to_i] },
|
|
164
|
+
"ancestors" => anc, "owner" => (d.owner.name rescue nil), "members" => members }
|
|
165
|
+
end
|
|
166
|
+
decls.sort_by { |d| d["name"] }
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
raise Error.new("rubydex_index_failed", "Rubydex declaration extraction failed: #{e.class}: #{e.message}")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def extract_constant_references(graph)
|
|
172
|
+
refs = []
|
|
173
|
+
graph.constant_references.each do |r|
|
|
174
|
+
f, line = loc_string(r.location)
|
|
175
|
+
next unless f
|
|
176
|
+
|
|
177
|
+
decl = begin
|
|
178
|
+
r.declaration&.name
|
|
179
|
+
rescue StandardError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
refs << { "file" => f, "line" => line, "declaration" => decl, "resolved" => r.is_a?(Rubydex::ResolvedConstantReference) }
|
|
183
|
+
end
|
|
184
|
+
refs.sort_by { |r| [r["file"], r["line"]] }
|
|
185
|
+
rescue StandardError
|
|
186
|
+
[]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|