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,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
|