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