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.
@@ -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