refactor(dynamic): enhance Ruby harness with framework-specific route replay logic (Sinatra, Rails, Hanami), extend Gemfile staging, and update tests/fixtures

This commit is contained in:
elipeter 2026-05-26 12:59:02 -05:00
parent 41c7b73575
commit aaf49acefb
20 changed files with 773 additions and 218 deletions

View file

@ -4211,7 +4211,10 @@ mod tests {
assert!(harness.source.contains("nyxPayload()"));
assert!(harness.source.contains("Entry.processInput(payload)"));
assert_eq!(harness.filename, "NyxHarness.java");
assert_eq!(harness.command, vec!["java", "-cp", ".", "NyxHarness"]);
assert_eq!(
harness.command,
vec!["java", "-cp", ".:lib/*", "NyxHarness"]
);
}
#[test]

View file

@ -3,13 +3,15 @@
//! Phase 15 (Track B Ruby vertical) replaces the previous `LangUnsupported`
//! stub with dispatch over [`RubyShape`] — the cross product of
//! [`EntryKind`] and a lightweight per-file shape detector that inspects
//! the entry file for Sinatra routes, Rails controller actions, Rack
//! middleware, and generic controller methods.
//! the entry file for Sinatra routes, Rails controller actions, Hanami
//! actions, Rack middleware, and generic controller methods.
//!
//! Each shape emits a single `harness.rb` that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Requires the entry file from the workdir (`entry.rb`).
//! 3. Invokes the entry point via the per-shape adapter.
//! 3. Invokes the entry point via the per-shape adapter. Framework routes
//! are replayed through Rack / ActionController / Hanami request entry
//! points instead of an in-process route registry.
//!
//! Sink-reachability probe: fixtures explicitly emit `__NYX_SINK_HIT__`
//! before the actual sink call (same pattern as Rust / JS / Go fixtures).
@ -17,14 +19,13 @@
//! Payload slot support:
//! - `PayloadSlot::Param(n)` — n-th positional argument.
//! - `PayloadSlot::EnvVar(name)` — set `ENV[name]` before calling.
//! - `PayloadSlot::QueryParam(name)` — surfaced via the per-shape
//! request stub for Sinatra / Rails / Rack.
//! - `PayloadSlot::HttpBody` — surfaced via the per-shape request stub
//! for Sinatra / Rails / Rack.
//! - `PayloadSlot::QueryParam(name)` — surfaced via the Rack request.
//! - `PayloadSlot::HttpBody` — surfaced via the Rack request body.
//! - `PayloadSlot::Argv(n)` — appended to `ARGV` for CLI-style entries.
//! - `PayloadSlot::Stdin` — produces `UnsupportedReason::PayloadSlotUnsupported`.
//!
//! Build: no compilation step. Command is `ruby harness.rb`.
//! Build: no compilation step. When the emitter stages a Gemfile,
//! `build_sandbox::prepare_ruby` runs Bundler before `ruby harness.rb`.
use crate::dynamic::environment::{Environment, RuntimeArtifacts};
use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter};
@ -37,8 +38,8 @@ pub struct RubyEmitter;
/// Entry kinds the Ruby emitter understands after Phase 15.
///
/// `HttpRoute` covers Sinatra / Rails / Rack. `CliSubcommand` covers
/// `ARGV`-driven scripts. `Function` covers plain methods and
/// `HttpRoute` covers Sinatra / Rails / Hanami / Rack. `CliSubcommand`
/// covers `ARGV`-driven scripts. `Function` covers plain methods and
/// controller method shapes.
const SUPPORTED: &[EntryKindTag] = &[
EntryKindTag::Function,
@ -131,17 +132,18 @@ fn ruby_string_literal(s: &str) -> String {
/// or no marker fires the detector defaults to [`RubyShape::Generic`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RubyShape {
/// `get '/path' do ... end` Sinatra route. Harness publishes the
/// payload via `ENV` + `$nyx_request` and triggers the route's
/// block via `$nyx_sinatra_routes`.
/// `get '/path' do ... end` Sinatra route. Harness sends a Rack
/// request into the detected Sinatra app.
SinatraRoute,
/// Rails controller action (e.g. `def index ... end` on a class
/// inheriting from `ApplicationController` / `ActionController::Base`).
/// Harness instantiates the controller and calls the action with a
/// stub `request` / `params` pair.
/// Harness calls the controller's Rack endpoint.
RailsAction,
/// Hanami action (`class RunAction < Hanami::Action` with `call`).
/// Harness invokes the real action object with a Rack env.
HanamiAction,
/// Rack middleware: `def call(env) ... end` on a class. Harness
/// builds a minimal Rack `env` hash and dispatches.
/// builds the env through Rack and dispatches the app.
RackMiddleware,
/// Generic instance method on a controller class (no framework
/// marker). Harness instantiates the class with `.new` and calls
@ -168,6 +170,10 @@ impl RubyShape {
|| source.contains("ActionController::Base")
|| source.contains("ActionController::API")
|| source.contains("# nyx-shape: rails");
let has_hanami = source.contains("require 'hanami/action'")
|| source.contains("require \"hanami/action\"")
|| source.contains("Hanami::Action")
|| source.contains("# nyx-shape: hanami");
let has_rack = source.contains("def call(env)")
|| source.contains("Rack::")
|| source.contains("# nyx-shape: rack");
@ -188,6 +194,9 @@ impl RubyShape {
if has_rails {
return Self::RailsAction;
}
if has_hanami {
return Self::HanamiAction;
}
if has_rack {
return Self::RackMiddleware;
}
@ -510,16 +519,35 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
let entry_source = read_entry_source(&spec.entry_file);
let shape = RubyShape::detect(spec, &entry_source);
let source = generate_source(spec, shape);
let extra_files = extra_files_for_shape(shape);
Ok(HarnessSource {
source,
filename: "harness.rb".to_owned(),
command: vec!["ruby".to_owned(), "harness.rb".to_owned()],
extra_files: vec![],
extra_files,
entry_subpath: Some("entry.rb".to_owned()),
})
}
fn extra_files_for_shape(shape: RubyShape) -> Vec<(String, String)> {
let deps: &[&str] = match shape {
RubyShape::SinatraRoute => &["rack", "sinatra"],
RubyShape::RailsAction => &["rack", "actionpack"],
RubyShape::HanamiAction => &["rack", "hanami-controller"],
RubyShape::RackMiddleware => &["rack"],
RubyShape::ControllerMethod | RubyShape::Generic => &[],
};
if deps.is_empty() {
return Vec::new();
}
let mut body = String::from("source 'https://rubygems.org'\n");
for dep in deps {
body.push_str(&format!("gem '{dep}'\n"));
}
vec![("Gemfile".to_owned(), body)]
}
/// Phase 19 (Track M.1) — class-method harness for Ruby.
///
/// Requires the entry file, looks up `class` as a top-level constant,
@ -2043,7 +2071,7 @@ STDOUT.flush
fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String {
let entry_fn = &spec.entry_name;
let pre_call = build_pre_call(spec);
let pre_call = build_pre_call(spec, shape);
let invocation = invoke_for_shape(spec, shape, entry_fn);
let shim = probe_shim();
let crash_callee = if entry_fn.is_empty() {
@ -2053,7 +2081,7 @@ fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String {
};
format!(
r#"# Nyx dynamic harness — auto-generated, do not edit (Phase 15 — RubyShape::{shape:?}).
r#"# Nyx dynamic harness — auto-generated, do not edit (RubyShape::{shape:?}).
{shim}
# Payload loading
def nyx_payload
@ -2073,26 +2101,60 @@ end
$nyx_payload = nyx_payload
begin
require 'uri'
require 'bundler/setup' if File.exist?(File.join(__dir__, 'Gemfile'))
rescue LoadError, StandardError => e
STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}")
exit 77
end
def _nyx_require_rack_mock
require 'rack/mock'
rescue LoadError => e
STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}")
exit 77
end
def _nyx_materialize_path(template, payload)
encoded = URI.encode_www_form_component(payload.to_s)
path = template.to_s.empty? ? '/' : template.to_s
path = path.gsub(/\{{[^}}]+\}}/, encoded)
path.gsub(/:[A-Za-z_][A-Za-z0-9_]*/, encoded)
end
def _nyx_request_uri
params = $nyx_request[:params] || {{}}
query = URI.encode_www_form(params)
path = $nyx_request[:path] || '/'
query.empty? ? path : path.to_s + '?' + query.to_s
end
def _nyx_rack_env
_nyx_require_rack_mock
env = Rack::MockRequest.env_for(
_nyx_request_uri,
method: ($nyx_request[:method] || 'GET'),
input: ($nyx_request[:body] || '')
)
env['nyx.payload'] = $nyx_payload
env
end
def _nyx_print_rack_body(body)
if body.respond_to?(:each)
body.each {{ |chunk| print(chunk.to_s) }}
elsif body
print(body.to_s)
end
end
# Phase 08 sink-site signal trap: install AFTER payload decode so a crash
# inside `nyx_payload` writes no Crash probe and routes the verifier to
# `Inconclusive(UnrelatedCrash)`. A fatal signal inside the entry call
# below DOES fire the handler and writes a Crash probe to `NYX_PROBE_PATH`.
__nyx_install_crash_guard('{crash_callee}')
{pre_call}
# Sinatra route registry
$nyx_sinatra_routes ||= []
unless Object.method_defined?(:__nyx_register_route)
module Kernel
def get(path, &block)
$nyx_sinatra_routes ||= []
$nyx_sinatra_routes << [path, :get, block]
end
def post(path, &block)
$nyx_sinatra_routes ||= []
$nyx_sinatra_routes << [path, :post, block]
end
end
end
# Entry require
begin
@ -2115,79 +2177,198 @@ end
)
}
fn build_pre_call(spec: &HarnessSpec) -> String {
fn build_pre_call(spec: &HarnessSpec, shape: RubyShape) -> String {
let mut out = String::new();
let (method, path_template) = route_for_spec(spec, shape);
let default_param = default_payload_param(spec);
let default_request = format!(
"$nyx_request = {{ method: {method:?}, path: _nyx_materialize_path({path_template:?}, $nyx_payload), params: {{ {default_param:?} => $nyx_payload }}, body: '' }}\n"
);
match &spec.payload_slot {
PayloadSlot::EnvVar(name) => {
out.push_str(&format!("ENV[{name:?}] = $nyx_payload\n"));
out.push_str(&default_request);
}
PayloadSlot::Argv(n) => {
for _ in 0..*n {
out.push_str("ARGV << ''\n");
}
out.push_str("ARGV << $nyx_payload\n");
out.push_str(&default_request);
}
PayloadSlot::QueryParam(name) => {
out.push_str(&format!(
"$nyx_request = {{ method: 'GET', path: '/', params: {{ {name:?} => $nyx_payload }}, body: '' }}\n"
"$nyx_request = {{ method: {method:?}, path: _nyx_materialize_path({path_template:?}, $nyx_payload), params: {{ {name:?} => $nyx_payload }}, body: '' }}\n"
));
}
PayloadSlot::HttpBody => {
out.push_str(
"$nyx_request = { method: 'POST', path: '/', params: {}, body: $nyx_payload }\n",
);
out.push_str(&format!(
"$nyx_request = {{ method: 'POST', path: _nyx_materialize_path({path_template:?}, $nyx_payload), params: {{}}, body: $nyx_payload }}\n"
));
}
_ => {
out.push_str(
"$nyx_request = { method: 'GET', path: '/', params: { 'payload' => $nyx_payload }, body: '' }\n",
);
out.push_str(&default_request);
}
}
out
}
fn default_payload_param(spec: &HarnessSpec) -> String {
spec.framework
.as_ref()
.and_then(|binding| {
binding
.request_params
.iter()
.find_map(|param| match &param.source {
crate::dynamic::framework::ParamSource::QueryParam(name)
| crate::dynamic::framework::ParamSource::FormField(name)
| crate::dynamic::framework::ParamSource::PathSegment(name) => {
Some(name.clone())
}
_ => None,
})
})
.unwrap_or_else(|| "payload".to_owned())
}
fn route_for_spec(spec: &HarnessSpec, shape: RubyShape) -> (String, String) {
if let Some(route) = spec
.framework
.as_ref()
.and_then(|binding| binding.route.as_ref())
{
return (
http_method_name(route.method).to_owned(),
route.path.clone(),
);
}
let source = read_entry_source(&spec.entry_file);
if let Some(found) = route_from_source(&source) {
return found;
}
match shape {
RubyShape::RailsAction => ("GET".to_owned(), format!("/{}", spec.entry_name)),
RubyShape::RackMiddleware => ("GET".to_owned(), "/".to_owned()),
RubyShape::SinatraRoute | RubyShape::HanamiAction => ("GET".to_owned(), "/run".to_owned()),
RubyShape::ControllerMethod | RubyShape::Generic => ("GET".to_owned(), "/".to_owned()),
}
}
fn route_from_source(source: &str) -> Option<(String, String)> {
if let Some(found) = pinned_route_from_source(source) {
return Some(found);
}
for line in source.lines() {
let trimmed = line.trim_start();
for verb in ["get", "post", "put", "patch", "delete", "options"] {
if let Some(rest) = trimmed.strip_prefix(verb)
&& rest.starts_with(char::is_whitespace)
&& let Some(path) = first_quoted_string(rest)
{
return Some((verb.to_ascii_uppercase(), path));
}
}
}
None
}
fn pinned_route_from_source(source: &str) -> Option<(String, String)> {
for line in source.lines() {
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix("# nyx-route:") else {
continue;
};
let mut parts = rest.split_ascii_whitespace();
let method = parts.next()?.to_ascii_uppercase();
let path = parts.next()?.to_owned();
return Some((method, path));
}
None
}
fn first_quoted_string(input: &str) -> Option<String> {
let trimmed = input.trim_start();
let quote = match trimmed.as_bytes().first()? {
b'\'' => '\'',
b'"' => '"',
_ => return None,
};
let rest = &trimmed[1..];
let end = rest.find(quote)?;
Some(rest[..end].to_owned())
}
fn http_method_name(method: crate::dynamic::framework::HttpMethod) -> &'static str {
match method {
crate::dynamic::framework::HttpMethod::GET => "GET",
crate::dynamic::framework::HttpMethod::HEAD => "HEAD",
crate::dynamic::framework::HttpMethod::POST => "POST",
crate::dynamic::framework::HttpMethod::PUT => "PUT",
crate::dynamic::framework::HttpMethod::PATCH => "PATCH",
crate::dynamic::framework::HttpMethod::DELETE => "DELETE",
crate::dynamic::framework::HttpMethod::OPTIONS => "OPTIONS",
}
}
fn invoke_for_shape(spec: &HarnessSpec, shape: RubyShape, entry_fn: &str) -> String {
match shape {
RubyShape::Generic => generic_invocation(spec, entry_fn),
RubyShape::SinatraRoute => format!(
r#" route = $nyx_sinatra_routes.find {{ |_, _, b| b }}
if route && route[2]
blk = route[2]
result = blk.call($nyx_payload)
print(result.to_s)
r#" _nyx_require_rack_mock
cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil
app = if cls && cls.respond_to?(:call)
cls
elsif defined?(Sinatra) && Sinatra.const_defined?(:Application)
Sinatra::Application
end
if app
response = Rack::MockRequest.new(app).request(
($nyx_request[:method] || 'GET'),
_nyx_request_uri,
input: ($nyx_request[:body] || '')
)
print(response.body.to_s)
elsif respond_to?({entry_fn:?})
print(send({entry_fn:?}, $nyx_payload).to_s)
end"#,
cls = entry_class_from_spec(spec),
),
RubyShape::RailsAction => {
let cls = entry_class_from_spec(spec);
format!(
r#" _nyx_require_rack_mock
cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil
if cls && cls.respond_to?(:action)
_status, _headers, body = cls.action({entry_fn:?}).call(_nyx_rack_env)
_nyx_print_rack_body(body)
end"#,
)
}
RubyShape::HanamiAction => {
let cls = entry_class_from_spec(spec);
format!(
r#" cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil
if cls
instance = cls.new
instance.instance_variable_set(:@__nyx_payload, $nyx_payload)
instance.instance_variable_set(:@__nyx_request, $nyx_request)
result = instance.send({entry_fn:?})
print(result.to_s) if result
action = cls.new
result = action.call(_nyx_rack_env)
if result.is_a?(Array) && result.length >= 3
_nyx_print_rack_body(result[2])
else
print(result.to_s) if result
end
end"#,
)
}
RubyShape::RackMiddleware => {
let cls = entry_class_from_spec(spec);
format!(
r#" require 'stringio'
cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil
r#" cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil
if cls
inner = cls.respond_to?(:new) ? (cls.method(:new).arity == 0 ? cls.new : cls.new(nil)) : nil
env = {{
'REQUEST_METHOD' => ($nyx_request[:method] rescue 'GET'),
'PATH_INFO' => ($nyx_request[:path] rescue '/'),
'QUERY_STRING' => "payload=#{{$nyx_payload}}",
'rack.input' => StringIO.new(($nyx_request[:body] rescue '')),
'nyx.payload' => $nyx_payload,
}}
env = _nyx_rack_env
status, headers, body = inner.call(env)
Array(body).each {{ |chunk| print(chunk.to_s) }}
_nyx_print_rack_body(body)
end"#,
)
}
@ -2249,7 +2430,7 @@ fn parse_class_hosting_method(source: &str, entry_name: &str) -> Option<String>
if let Some(rest) = l.strip_prefix("class ") {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':')
.collect();
if !name.is_empty() {
last_class = Some(name);
@ -2269,7 +2450,7 @@ fn parse_first_class_name(source: &str) -> Option<String> {
if let Some(rest) = l.strip_prefix("class ") {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':')
.collect();
if !name.is_empty() {
return Some(name);
@ -2391,6 +2572,13 @@ mod tests {
assert_eq!(RubyShape::detect(&spec, src), RubyShape::RailsAction);
}
#[test]
fn shape_detect_hanami_action() {
let src = "require 'hanami/action'\nclass RunAction < Hanami::Action\n def call(req)\n 'ok'\n end\nend\n";
let spec = make_spec_with(EntryKind::HttpRoute, "call", "entry.rb");
assert_eq!(RubyShape::detect(&spec, src), RubyShape::HanamiAction);
}
#[test]
fn shape_detect_rack_middleware() {
let src = "class MyMiddleware\n def call(env)\n [200, {}, ['ok']]\n end\nend\n";
@ -2413,26 +2601,56 @@ mod tests {
}
#[test]
fn sinatra_shape_uses_route_registry() {
fn sinatra_shape_uses_rack_request() {
let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.rb");
let src = generate_source(&spec, RubyShape::SinatraRoute);
assert!(src.contains("$nyx_sinatra_routes"));
assert!(src.contains("Rack::MockRequest.new(app).request"));
assert!(!src.contains("$nyx_sinatra_routes"));
}
#[test]
fn rack_shape_builds_env_hash() {
fn rack_shape_builds_env_through_rack() {
let mut spec = make_spec_with(EntryKind::HttpRoute, "call", "entry.rb");
spec.payload_slot = PayloadSlot::QueryParam("payload".into());
let src = generate_source(&spec, RubyShape::RackMiddleware);
assert!(src.contains("REQUEST_METHOD"));
assert!(src.contains("rack.input"));
assert!(src.contains("Rack::MockRequest.env_for"));
assert!(src.contains("env['nyx.payload'] = $nyx_payload"));
}
#[test]
fn rails_shape_invokes_action_on_instance() {
fn rails_shape_invokes_action_rack_endpoint() {
let spec = make_spec_with(EntryKind::HttpRoute, "index", "entry.rb");
let src = generate_source(&spec, RubyShape::RailsAction);
assert!(src.contains("instance.send"));
assert!(src.contains("cls.action(\"index\").call(_nyx_rack_env)"));
}
#[test]
fn hanami_shape_invokes_action_with_rack_env() {
let spec = make_spec_with(EntryKind::HttpRoute, "call", "entry.rb");
let src = generate_source(&spec, RubyShape::HanamiAction);
assert!(src.contains("action.call(_nyx_rack_env)"));
}
#[test]
fn framework_shapes_stage_gemfile() {
let sinatra = extra_files_for_shape(RubyShape::SinatraRoute);
assert!(
sinatra
.iter()
.any(|(p, c)| p == "Gemfile" && c.contains("sinatra"))
);
let rails = extra_files_for_shape(RubyShape::RailsAction);
assert!(
rails
.iter()
.any(|(p, c)| p == "Gemfile" && c.contains("actionpack"))
);
let hanami = extra_files_for_shape(RubyShape::HanamiAction);
assert!(
hanami
.iter()
.any(|(p, c)| p == "Gemfile" && c.contains("hanami-controller"))
);
}
#[test]
@ -2452,6 +2670,10 @@ mod tests {
parse_first_class_name("class Bar < Base\nend\n"),
Some("Bar".to_owned())
);
assert_eq!(
parse_first_class_name("class Books::Show < Hanami::Action\nend\n"),
Some("Books::Show".to_owned())
);
assert_eq!(parse_first_class_name("def foo\nend\n"), None);
}