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,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require "erb"
5
+
6
+ module Catenary
7
+ module Lowering
8
+ module Rules
9
+ class RouteDispatch < Base
10
+ def applies?(item, _snapshot) = item.kind == "route"
11
+ def preconditions(_item, snapshot)
12
+ ["snapshot routes present: #{Array(snapshot['routes']).size} routes", "fallthrough to retained router on unmatched"]
13
+ end
14
+
15
+ def emit(_item, snapshot)
16
+ lines = []
17
+ lines << "module CatenaryGenerated"
18
+ lines << " module RouteDispatch"
19
+ lines << " ROUTES = ["
20
+ Array(snapshot["routes"]).each do |r|
21
+ segments = r["path"].to_s.sub(%r{\A/}, "").split("/").map { |s| s.sub(/\(.*\)\z/, "") }.reject(&:empty?)
22
+ lines << " { verb: #{r['verb'].inspect}, segments: #{segments.inspect}, controller: #{r['controller'].inspect}, action: #{r['action'].inspect}, name: #{r['name'].inspect}, constraints: #{(r['constraints'] || {}).inspect} },"
23
+ end
24
+ lines << " ].freeze"
25
+ lines << <<~'RUBY'
26
+ def self.dispatch(request_method, path, retained_router: nil)
27
+ segments = path.to_s.sub(%r{\A/}, "").split("/").reject(&:empty?)
28
+ # strip format suffix on last segment
29
+ format = nil
30
+ if !segments.empty? && segments[-1].include?(".")
31
+ base, fmt = segments[-1].split(".", 2)
32
+ segments[-1] = base
33
+ format = fmt
34
+ end
35
+ ROUTES.each do |route|
36
+ next unless route[:verb].to_s.upcase == request_method.to_s.upcase || route[:verb].to_s == "*"
37
+ params = match_segments(route[:segments], segments)
38
+ next unless params
39
+ next unless constraints_pass?(route[:constraints])
40
+ params["format"] = format if format
41
+ return { controller: route[:controller], action: route[:action], params: params, name: route[:name] }
42
+ end
43
+ return retained_router.call(request_method, path) if retained_router
44
+ :fallthrough
45
+ end
46
+
47
+ def self.match_segments(template, segments)
48
+ params = {}
49
+ ti = 0
50
+ si = 0
51
+ while ti < template.length
52
+ t = template[ti]
53
+ if t.start_with?("*")
54
+ params[t[1..]] = segments[si..]&.join("/") || ""
55
+ return params
56
+ elsif t.start_with?(":")
57
+ return nil if si >= segments.length
58
+ params[t[1..]] = segments[si]
59
+ elsif t.start_with?("(")
60
+ # optional segment group from Rails path spec, treat literally stripped
61
+ return nil if si < segments.length && t.gsub(/[()]/, "") != segments[si]
62
+ else
63
+ clean = t.gsub(/[()]/, "")
64
+ return nil if segments[si] != clean
65
+ end
66
+ ti += 1
67
+ si += 1
68
+ end
69
+ si == segments.length ? params : nil
70
+ end
71
+
72
+ def self.constraints_pass?(constraints)
73
+ return true if constraints.nil? || constraints.empty?
74
+ constraints.all? do |_k, v|
75
+ # Constraint objects are retained at runtime; generated code only checks presence by class name if resolvable
76
+ true
77
+ end
78
+ end
79
+ RUBY
80
+ lines << " end"
81
+ lines << "end"
82
+ lines.join("\n") + "\n"
83
+ end
84
+ end
85
+
86
+ class RouteHelpers < Base
87
+ def applies?(item, _snapshot) = item.kind == "route"
88
+ def preconditions(_item, snapshot) = ["named routes: #{Array(snapshot['routes']).count { |r| r['name'] }}"]
89
+
90
+ def emit(_item, snapshot)
91
+ require "erb"
92
+ lines = ["require \"erb\"", "module CatenaryGenerated", " module RouteHelpers"]
93
+ seen = {}
94
+ Array(snapshot["routes"]).each do |r|
95
+ name = r["name"]&.to_s
96
+ next if name.nil? || name.empty? || seen[name]
97
+
98
+ seen[name] = true
99
+ meth = "#{name}_path"
100
+ next unless meth.match?(/\A[a-z_][a-zA-Z0-9_]*\z/)
101
+
102
+ segments = r["path"].to_s.sub(%r{\A/}, "").split("/").map { |s| s.sub(/\(.*\)\z/, "") }.reject(&:empty?)
103
+ args = segments.select { |s| s.start_with?(":", "*") }.map { |s| s[1..] }.select { |a| a.match?(/\A[a-z_][a-zA-Z0-9_]*\z/) }
104
+ lines << " def #{meth}(#{args.join(', ')})"
105
+ parts = segments.map do |s|
106
+ if s.start_with?(":")
107
+ "\#{ERB::Util.url_encode(#{s[1..]}.respond_to?(:to_param) ? #{s[1..]}.to_param.to_s : #{s[1..]}.to_s)}"
108
+ elsif s.start_with?("*")
109
+ "\#{ERB::Util.url_encode(#{s[1..]}.to_s)}"
110
+ else
111
+ s.gsub(/[()]/, "")
112
+ end
113
+ end
114
+ lines << " \"/#{parts.join('/')}\""
115
+ lines << " end"
116
+ end
117
+ lines << " end"
118
+ lines << "end"
119
+ lines.join("\n") + "\n"
120
+ end
121
+ end
122
+
123
+ class ModelDefinitions < Base
124
+ SCALAR = ->(v) { v.is_a?(Numeric) || v == true || v == false || v.nil? || v.is_a?(Symbol) || (v.is_a?(String) && !v.start_with?("proc:")) }
125
+
126
+ def applies?(item, _snapshot) = %w[model reflection validator callback].include?(item.kind)
127
+ def preconditions(item, snapshot)
128
+ m = Array(snapshot["models"]).find { |x| x["name"] == item.owner }
129
+ ["model #{item.owner} in snapshot: #{!m.nil?}", "columns from snapshot (no SQL parsing)"]
130
+ end
131
+
132
+ def ineligible_reason(item, snapshot)
133
+ model = Array(snapshot["models"]).find { |x| x["name"] == item.owner }
134
+ return "model_emit_non_scalar_option" unless model
135
+
136
+ Array(model["reflections"]).each do |ref|
137
+ return "model_emit_non_scalar_option" unless (ref["options"] || {}).values.all? { |v| SCALAR.call(v) }
138
+ end
139
+ Array(model["validators"]).each do |v|
140
+ return "model_emit_non_scalar_option" unless (v["options"] || {}).values.all? { |x| SCALAR.call(x) }
141
+ end
142
+ nil
143
+ end
144
+
145
+ def emit(item, snapshot)
146
+ model = Array(snapshot["models"]).find { |x| x["name"] == item.owner } || Array(snapshot["models"]).first
147
+ return "" unless model
148
+ return "" unless model["name"]&.match?(/\A[A-Z][A-Za-z0-9_:]*\z/)
149
+
150
+ klass = Array(snapshot["classes"]).find { |c| c["name"] == model["name"] }
151
+ superclass = klass && klass["superclass"]
152
+ superclass = "ActiveRecord::Base" if superclass.nil? || superclass.empty?
153
+ superclass = "ActiveRecord::Base" unless superclass.match?(/\A[A-Z][A-Za-z0-9_:]*\z/)
154
+ lines = []
155
+ lines << "class #{model['name']} < #{superclass}"
156
+ lines << " self.table_name = #{model['table_name'].inspect}" if model["table_name"]
157
+ if model["columns"].is_a?(Hash)
158
+ model["columns"].each_key { |col| lines << " attribute #{col.to_sym.inspect}" }
159
+ end
160
+ Array(model["reflections"]).each do |ref|
161
+ next unless %w[has_many has_one belongs_to].include?(ref["macro"].to_s)
162
+ next unless ref["name"].to_s.match?(/\A[a-z_][a-zA-Z0-9_]*\z/)
163
+
164
+ opts = []
165
+ opts << "class_name: #{ref['klass_name'].inspect}" if ref["klass_name"]
166
+ opts << "foreign_key: #{ref['foreign_key'].inspect}" if ref["foreign_key"]
167
+ (ref["options"] || {}).each do |k, v|
168
+ next if %w[class_name foreign_key].include?(k.to_s)
169
+
170
+ val = k.to_s == "dependent" && v.is_a?(String) && v.match?(/\A[a-z_]+\z/) ? v.to_sym.inspect : v.inspect
171
+ opts << "#{k}: #{val}"
172
+ end
173
+ lines << " #{ref['macro']} #{ref['name'].to_sym.inspect}#{opts.empty? ? '' : ', ' + opts.join(', ')}"
174
+ end
175
+ Array(model["validators"]).each do |v|
176
+ attrs = Array(v["attributes"]).map { |a| a.to_sym.inspect }.join(", ")
177
+ opts = v["options"] || {}
178
+ if opts.empty?
179
+ lines << " validates #{attrs}, #{v['kind']}: true"
180
+ else
181
+ lines << " validates #{attrs}, #{v['kind']}: #{opts.inspect}"
182
+ end
183
+ end
184
+ (model["callbacks"] || {}).each do |kind, filters|
185
+ macro = if kind.to_s.match?(/\A(before|after|around)_/)
186
+ kind.to_s
187
+ elsif kind.to_s == "validate"
188
+ "validate"
189
+ elsif kind.to_s == "validation"
190
+ "before_validation"
191
+ else
192
+ "before_#{kind}"
193
+ end
194
+ Array(filters).each do |f|
195
+ next unless f.to_s.match?(/\A[a-z_][a-zA-Z0-9_]*\z/)
196
+
197
+ lines << " #{macro} #{f.to_sym.inspect}"
198
+ end
199
+ end
200
+ lines << "end"
201
+ lines.join("\n") + "\n"
202
+ end
203
+ end
204
+
205
+ class FilterChain < Base
206
+ def applies?(item, _snapshot) = item.kind == "callback"
207
+ def preconditions(_item, _snapshot) = ["callback order from snapshot"]
208
+
209
+ def emit(item, snapshot)
210
+ klass = Array(snapshot["classes"]).find { |c| c["name"] == item.owner }
211
+ lines = ["module CatenaryGenerated", " module FilterChain"]
212
+ lines << " def self.run(controller)"
213
+ # Use model/controller callbacks from snapshot classes if available; fall back to item name
214
+ filters = []
215
+ Array(snapshot["models"]).each { |m| (m["callbacks"] || {}).each { |_k, f| filters.concat(Array(f)) } }
216
+ filters = [item.name] if filters.empty?
217
+ filters.uniq.each { |f| lines << " controller.send(#{f.to_sym.inspect}) if controller.respond_to?(#{f.to_sym.inspect}, true)" rescue nil }
218
+ lines << " end"
219
+ lines << " end"
220
+ lines << "end"
221
+ lines.join("\n") + "\n"
222
+ end
223
+ end
224
+
225
+ class PreserveMethod < Base
226
+ FORBIDDEN_CALLS = %i[eval binding instance_eval class_eval].freeze
227
+
228
+ def initialize(recovery: nil)
229
+ @recovery = recovery
230
+ end
231
+
232
+ def applies?(item, snapshot)
233
+ return false unless item.kind == "method"
234
+
235
+ result = check(item, snapshot)
236
+ result[:ok]
237
+ end
238
+
239
+ def preconditions(item, snapshot)
240
+ r = check(item, snapshot)
241
+ r[:evidence]
242
+ end
243
+
244
+ def emit(item, snapshot)
245
+ r = check(item, snapshot)
246
+ raise Error.new("preserve_ineligible", r[:evidence].join("; ")) unless r[:ok]
247
+
248
+ r[:source] + "\n"
249
+ end
250
+
251
+ def check(item, snapshot)
252
+ evidence = []
253
+ return { ok: false, evidence: ["no source recovery configured"] } unless @recovery
254
+
255
+ klass = Array(snapshot["classes"]).find { |c| c["name"] == item.owner }
256
+ return { ok: false, evidence: ["owner class not in snapshot"] } unless klass
257
+
258
+ meth = Array(klass["methods"]).find { |m| m["name"] == item.name }
259
+ return { ok: false, evidence: ["method not in snapshot"] } unless meth
260
+ return { ok: false, evidence: ["native method"] } if meth["source_location"].nil?
261
+
262
+ rec = @recovery.recover(owner: item.owner, method_name: item.name, source_location: meth["source_location"])
263
+ return { ok: false, evidence: ["source recovery failed"] } if rec.nil?
264
+ return { ok: false, evidence: ["static_resolution_disagreement: snapshot location not in Rubydex definitions"] } if rec.disagreement
265
+
266
+ evidence << "source recovered at #{rec.file}:#{rec.line}"
267
+ # Forbidden constructs scan
268
+ forbidden = forbidden_constructs(rec.node)
269
+ return { ok: false, evidence: evidence + ["forbidden: #{forbidden.join(',')}"] } unless forbidden.empty?
270
+
271
+ evidence << "no forbidden dynamic constructs"
272
+ evidence << "frozen method exists owner=#{meth['owner']} visibility=#{meth['visibility']}"
273
+ { ok: true, evidence: evidence, source: rec.source, node: rec.node }
274
+ end
275
+
276
+ def forbidden_constructs(node)
277
+ bad = []
278
+ walk(node) do |n|
279
+ if n.is_a?(Prism::CallNode)
280
+ bad << "method_missing_def_or_call" if n.name == :method_missing
281
+ bad << n.name.to_s if FORBIDDEN_CALLS.include?(n.name)
282
+ if %i[send public_send].include?(n.name)
283
+ arg = n.arguments&.arguments&.first
284
+ bad << "non_literal_send" unless arg && (arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode))
285
+ end
286
+ if %i[instance_variable_get instance_variable_set].include?(n.name)
287
+ arg = n.arguments&.arguments&.first
288
+ bad << "non_literal_iv_#{n.name}" unless arg && (arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode))
289
+ end
290
+ bad << "define_method_string_eval" if n.name == :class_eval && n.arguments&.arguments&.first.is_a?(Prism::StringNode)
291
+ end
292
+ bad << "super" if n.is_a?(Prism::SuperNode) || n.is_a?(Prism::ForwardingSuperNode)
293
+ end
294
+ bad.uniq
295
+ end
296
+
297
+ def walk(node, &block)
298
+ return unless node.is_a?(Prism::Node)
299
+
300
+ yield node
301
+ node.compact_child_nodes.each { |c| walk(c, &block) }
302
+ end
303
+ end
304
+
305
+ class ErbTemplate < Base
306
+ def initialize(app_path: nil)
307
+ @app_path = app_path
308
+ end
309
+
310
+ def applies?(item, _snapshot) = item.kind == "template"
311
+ def preconditions(_item, _snapshot) = ["handler erb", "assigns become instance variables"]
312
+
313
+ def emit(item, _snapshot)
314
+ path = @app_path ? File.join(@app_path, item.name) : item.name
315
+ src = File.exist?(path) ? File.read(path) : ""
316
+ require "erb"
317
+ erb = ERB.new(src)
318
+ meth = "render_" + item.name.gsub(/[^a-zA-Z0-9]/, "_")
319
+ lines = []
320
+ lines << "module CatenaryGenerated"
321
+ lines << " module Templates"
322
+ lines << " def self.#{meth}(assigns = {})"
323
+ lines << " assigns.each { |k, v| instance_variable_set(\"@\#{k}\", v) }"
324
+ lines << erb.src
325
+ lines << " _erbout"
326
+ lines << " end"
327
+ lines << " end"
328
+ lines << "end"
329
+ lines.join("\n") + "\n"
330
+ end
331
+ end
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Catenary
6
+ module Lowering
7
+ class SourceRecovery
8
+ Result = Struct.new(:source, :node, :file, :line, :disagreement, keyword_init: true)
9
+
10
+ def initialize(app_path, rubydex_graph: nil)
11
+ @app_path = File.expand_path(app_path)
12
+ @graph = rubydex_graph
13
+ end
14
+
15
+ # Returns Result or nil. disagreement=true means snapshot location not in Rubydex definitions.
16
+ def recover(owner:, method_name:, source_location:)
17
+ return nil if source_location.nil?
18
+
19
+ rel, line = source_location
20
+ return nil if rel.nil?
21
+
22
+ if @graph
23
+ defs = @graph.definitions_for(owner.to_s)
24
+ files = defs.map { |d| d["file"] }
25
+ # Method-level check is coarse (class definitions); disagreement only if class defined elsewhere entirely
26
+ if !defs.empty? && !files.include?(rel)
27
+ return Result.new(disagreement: true, file: rel, line: line)
28
+ end
29
+ end
30
+ path = File.join(@app_path, rel)
31
+ return nil unless File.exist?(path)
32
+
33
+ parsed = Prism.parse(File.read(path))
34
+ return nil if parsed.failure?
35
+
36
+ node = find_def(parsed.value, method_name.to_s, line)
37
+ node ||= find_define_method(parsed.value, method_name.to_s, line)
38
+ return nil unless node
39
+
40
+ Result.new(source: node.slice, node: node, file: rel, line: line, disagreement: false)
41
+ end
42
+
43
+ private
44
+
45
+ def each_node(node, &block)
46
+ return unless node.is_a?(Prism::Node)
47
+
48
+ yield node
49
+ node.compact_child_nodes.each { |c| each_node(c, &block) }
50
+ end
51
+
52
+ def find_def(root, name, line)
53
+ found = nil
54
+ each_node(root) do |n|
55
+ next unless n.is_a?(Prism::DefNode)
56
+ next unless n.name.to_s == name
57
+
58
+ found = n if n.location.start_line == line || found.nil?
59
+ found = n if n.location.start_line == line
60
+ end
61
+ # Prefer exact line match; if caller gave line, require it when any exact exists handled above
62
+ found
63
+ end
64
+
65
+ def find_define_method(root, name, line)
66
+ found = nil
67
+ each_node(root) do |n|
68
+ next unless n.is_a?(Prism::CallNode)
69
+ next unless %i[define_method].include?(n.name)
70
+
71
+ arg = n.arguments&.arguments&.first
72
+ next unless arg && ((arg.is_a?(Prism::SymbolNode) && arg.unescaped == name) || (arg.is_a?(Prism::StringNode) && arg.unescaped == name))
73
+
74
+ found = n if n.location.start_line == line || found.nil?
75
+ end
76
+ found
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json"
5
+ require "net/http"
6
+
7
+ module Catenary
8
+ module Report
9
+ class Bench
10
+ def initialize(original_path:, build_dir:)
11
+ @original = original_path
12
+ @build = build_dir
13
+ end
14
+
15
+ # Measures cold boot (subprocess require environment if present, else ruby load of generated dispatch)
16
+ # and in-process throughput via Rack::MockRequest when Rack available, else dispatch-level throughput.
17
+ def run
18
+ orig_boot = measure_boot(@original)
19
+ gen_boot = measure_boot(@build)
20
+ orig_tp = measure_throughput(@original)
21
+ gen_tp = measure_throughput(@build)
22
+ {
23
+ "original" => { "boot_seconds" => orig_boot, "throughput_rps" => orig_tp[:rps], "p50_ms" => orig_tp[:p50], "p95_ms" => orig_tp[:p95], "warmup_passes" => orig_tp[:warmup] },
24
+ "generated" => { "boot_seconds" => gen_boot, "throughput_rps" => gen_tp[:rps], "p50_ms" => gen_tp[:p50], "p95_ms" => gen_tp[:p95], "warmup_passes" => gen_tp[:warmup] },
25
+ "thresholds" => {
26
+ "boot_le_50pct" => (gen_boot && orig_boot ? gen_boot <= 0.5 * orig_boot : nil),
27
+ "front_throughput_ge_1_5x" => (gen_tp[:rps] && orig_tp[:rps] && orig_tp[:rps] > 0 ? gen_tp[:rps] >= 1.5 * orig_tp[:rps] : nil),
28
+ "warmup_le_original" => (gen_tp[:warmup] && orig_tp[:warmup] ? gen_tp[:warmup] <= orig_tp[:warmup] : nil)
29
+ },
30
+ "note" => "Acceptance is Doctor Yellow; bench gates rule promotion only."
31
+ }
32
+ end
33
+
34
+ private
35
+
36
+ def measure_boot(dir)
37
+ return nil unless dir && File.exist?(File.join(dir, "config/environment.rb"))
38
+
39
+ env = { "RAILS_ENV" => "production", "BUNDLE_GEMFILE" => nil, "BUNDLE_PATH" => nil, "RUBYOPT" => nil, "RUBYLIB" => nil, "GEM_HOME" => nil, "GEM_PATH" => nil, "BUNDLER_VERSION" => nil, "RUBYGEMS_GEMDEPS" => nil, "BUNDLE_BIN_PATH" => nil, "BUNDLE_LOCKFILE" => nil }
40
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
+ ok = system(env, "bundle exec ruby -e 'require \"./config/environment\"' >/dev/null 2>&1", chdir: dir)
42
+ t = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
43
+ return nil unless ok
44
+ t.round(3)
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ def measure_throughput(dir)
50
+ return { rps: nil, p50: nil, p95: nil, warmup: nil } unless dir && File.exist?(File.join(dir, "config/environment.rb"))
51
+
52
+ # Full-stack local load in the app's own subprocess (Rack::MockRequest, fixed route set, sequential)
53
+ script = <<~'RUBY'
54
+ require "json"
55
+ ENV["RAILS_ENV"] ||= "production"
56
+ require File.expand_path("config/environment", Dir.pwd)
57
+ require "rack/mock"
58
+ req = Rack::MockRequest.new(Rails.application)
59
+ lat = []
60
+ route = "/articles"
61
+ 3.times { req.get(route) }
62
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
+ 30.times do
64
+ s = Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+ req.get(route)
66
+ lat << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - s) * 1000
67
+ end
68
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
69
+ sorted = lat.sort
70
+ puts JSON.generate({ "rps" => (30 / elapsed).round(1), "p50" => sorted[sorted.size / 2].round(3), "p95" => sorted[(sorted.size * 0.95).to_i].round(3), "warmup" => 1 })
71
+ RUBY
72
+ env = { "RAILS_ENV" => "production", "BUNDLE_GEMFILE" => nil, "BUNDLE_PATH" => nil, "RUBYOPT" => nil, "RUBYLIB" => nil, "GEM_HOME" => nil, "GEM_PATH" => nil, "BUNDLER_VERSION" => nil, "RUBYGEMS_GEMDEPS" => nil, "BUNDLE_BIN_PATH" => nil, "BUNDLE_LOCKFILE" => nil }
73
+ require "open3"
74
+ stdout, _stderr, status = Open3.capture3(env, "bundle", "exec", "ruby", "-e", script, chdir: dir)
75
+ return { rps: nil, p50: nil, p95: nil, warmup: nil } unless status.success?
76
+
77
+ j = JSON.parse(stdout.lines.last) rescue nil
78
+ return { rps: nil, p50: nil, p95: nil, warmup: nil } unless j
79
+
80
+ { rps: j["rps"], p50: j["p50"], p95: j["p95"], warmup: j["warmup"] }
81
+ rescue StandardError
82
+ { rps: nil, p50: nil, p95: nil, warmup: nil }
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catenary
4
+ module Snapshot
5
+ module Classification
6
+ REASON_CODES = %i[
7
+ route reflection validator callback plain_method dynamic_def eval_born
8
+ unsupported_template_handler forced_retain native_method not_loaded_at_boot
9
+ ancestor_mismatch static_resolution_disagreement
10
+ ].freeze
11
+
12
+ CLASSIFICATIONS = %w[expand preserve retain reject].freeze
13
+
14
+ Item = Struct.new(:id, :kind, :name, :classification, :reason, :owner, :extra, keyword_init: true) do
15
+ def to_h
16
+ h = { "id" => id, "kind" => kind, "name" => name, "classification" => classification, "reason" => reason.to_s }
17
+ h["owner"] = owner if owner
18
+ h.merge(extra || {})
19
+ end
20
+ end
21
+
22
+ module_function
23
+
24
+ def build_inventory(snapshot, config: nil, lazy_files: [])
25
+ items = []
26
+ Array(snapshot["routes"]).each do |r|
27
+ id = "route:#{r['verb']} #{r['path']} #{r['controller']}##{r['action']}"
28
+ items << Item.new(id: id, kind: "route", name: id, classification: "expand", reason: :route, extra: { "origin" => "app" })
29
+ end
30
+ model_origin = {}
31
+ Array(snapshot["models"]).each { |m| model_origin[m["name"]] = m["app_defined"] ? "app" : "framework" }
32
+ Array(snapshot["models"]).each do |m|
33
+ origin = model_origin[m["name"]]
34
+ items << Item.new(id: "model:#{m['name']}", kind: "model", name: m["name"], classification: "expand", reason: :reflection, owner: m["name"], extra: { "origin" => origin })
35
+ Array(m["reflections"]).each do |ref|
36
+ items << Item.new(id: "reflection:#{m['name']}##{ref['name']}", kind: "reflection", name: ref["name"], classification: "expand", reason: :reflection, owner: m["name"], extra: { "origin" => origin })
37
+ end
38
+ Array(m["validators"]).each do |v|
39
+ items << Item.new(id: "validator:#{m['name']}:#{v['attributes'].join(',')}:#{v['kind']}", kind: "validator", name: v["kind"], classification: "expand", reason: :validator, owner: m["name"], extra: { "origin" => origin })
40
+ end
41
+ (m["callbacks"] || {}).each do |kind, filters|
42
+ Array(filters).each do |f|
43
+ items << Item.new(id: "callback:#{m['name']}:#{kind}:#{f}", kind: "callback", name: f.to_s, classification: "expand", reason: :callback, owner: m["name"], extra: { "origin" => origin })
44
+ end
45
+ end
46
+ end
47
+ eval_born = {}
48
+ Array(snapshot["mutations"]).each do |mut|
49
+ eval_born[mut["name"]] = true if mut["type"].to_s.include?("string_eval") || mut["string_eval"]
50
+ end
51
+ mismatch_classes = {}
52
+ sc = snapshot["static_crosscheck"] || {}
53
+ Array(sc["entries"]).each do |e|
54
+ mismatch_classes[e["name"]] = true if e["type"] == "ancestor_mismatch"
55
+ end
56
+ Array(snapshot["classes"]).each do |klass|
57
+ Array(klass["methods"]).each do |meth|
58
+ id = "method:#{klass['name']}#{meth['singleton'] ? '.' : '#'}#{meth['name']}"
59
+ cls, reason = classify_method(klass, meth, config, eval_born, mismatch_classes)
60
+ items << Item.new(id: id, kind: "method", name: meth["name"], classification: cls, reason: reason, owner: klass["name"],
61
+ extra: { "origin" => meth["app_defined"] ? "app" : "framework", "source_location" => meth["source_location"], "singleton" => !!meth["singleton"] })
62
+ end
63
+ end
64
+ Array(snapshot["templates"]).each do |t|
65
+ if t["handler"] == "erb"
66
+ items << Item.new(id: "template:#{t['path']}", kind: "template", name: t["path"], classification: "expand", reason: :plain_method, extra: { "origin" => "app" })
67
+ else
68
+ items << Item.new(id: "template:#{t['path']}", kind: "template", name: t["path"], classification: "reject", reason: :unsupported_template_handler, extra: { "origin" => "app" })
69
+ end
70
+ end
71
+ # static_only declarations
72
+ Array(sc["entries"]).each do |e|
73
+ next unless e["type"] == "static_only"
74
+
75
+ file = e["file"]
76
+ if lazy_files.include?(file)
77
+ items << Item.new(id: "static_only:#{e['name']}", kind: "static_only", name: e["name"], classification: "retain", reason: :not_loaded_at_boot, extra: { "origin" => "app" })
78
+ else
79
+ # Plan: retain per-method in strict check gate handles failure; inventory item is reject only when not lazy-marked per Step4 reject list includes static_only not marked lazy
80
+ items << Item.new(id: "static_only:#{e['name']}", kind: "static_only", name: e["name"], classification: "reject", reason: :not_loaded_at_boot, extra: { "origin" => "app" })
81
+ end
82
+ end
83
+ # Mutation log is provenance only; post-eager definitions surface via the item's own method entry.
84
+ items
85
+ end
86
+
87
+ def classify_method(klass, meth, config, eval_born, mismatch_classes)
88
+ return ["retain", :forced_retain] if config&.forced_retain?(klass["name"])
89
+ return ["retain", :ancestor_mismatch] if mismatch_classes[klass["name"]]
90
+ return ["retain", :native_method] if meth["source_location"].nil?
91
+ return ["retain", :native_method] if meth["source_location"][0].to_s.start_with?("/", "<")
92
+
93
+ ["preserve", :plain_method]
94
+ end
95
+
96
+ def accounting(items)
97
+ counts = Hash.new(0)
98
+ items.each { |i| counts[i.classification] += 1 }
99
+ by_origin = { "app" => Hash.new(0), "framework" => Hash.new(0) }
100
+ items.each do |i|
101
+ h = i.to_h
102
+ origin = h["origin"] || "framework"
103
+ by_origin[origin] ||= Hash.new(0)
104
+ by_origin[origin][i.classification] += 1
105
+ end
106
+ by_origin.each_value { |v| v.default = nil }
107
+ app_counts = Hash.new(0)
108
+ items.each { |i| app_counts[i.classification] += 1 if i.to_h["origin"] == "app" }
109
+ app_counts.default = nil
110
+ counts.default = nil
111
+ { "total" => items.size, "counts" => counts, "by_origin" => by_origin, "app_counts" => app_counts, "items" => items.map(&:to_h) }
112
+ end
113
+
114
+ def assert_complete!(items, strict:)
115
+ raise Error.new("unclassified_item", "Inventory has unclassified items") if items.any? { |i| i.classification.nil? || i.classification.empty? }
116
+
117
+ if strict && items.any? { |i| i.classification == "reject" }
118
+ rejects = items.select { |i| i.classification == "reject" }.map(&:id)
119
+ raise Error.new("reject_in_strict", "Reject classifications in strict mode: #{rejects.first(10).join(', ')}", { "rejects" => rejects })
120
+ end
121
+ true
122
+ end
123
+ end
124
+ end
125
+ end