diff --git a/src/dynamic/framework/adapters/rust_routes.rs b/src/dynamic/framework/adapters/rust_routes.rs index dde0c11c..2911f0fd 100644 --- a/src/dynamic/framework/adapters/rust_routes.rs +++ b/src/dynamic/framework/adapters/rust_routes.rs @@ -207,18 +207,38 @@ pub fn extract_rust_path_placeholders(path: &str) -> Vec { /// [`ParamSource::PathSegment`]; `req` / `request` / `state` formals /// fall to [`ParamSource::Implicit`]; every other formal becomes a /// [`ParamSource::QueryParam`]. +/// +/// warp's `warp::path!("users" / u32)` macro reconstructs placeholders +/// as type names (`u32`) rather than parameter names because the +/// segments are positional. When the placeholder list contains +/// typed-anonymous segments (Rust primitive type names like `u32` / +/// `String` / `Uuid`), the n-th typed-anonymous placeholder binds +/// positionally to the n-th non-implicit formal so handler signatures +/// like `fn show(id: u32)` bind `id` as a path segment instead of a +/// query param. pub fn bind_rust_path_params(formals: &[String], path: &str) -> Vec { let placeholders = extract_rust_path_placeholders(path); + let typed_anon_count = placeholders + .iter() + .filter(|p| is_typed_anonymous_placeholder(p)) + .count(); + let mut non_implicit_seen = 0usize; formals .iter() .enumerate() .map(|(idx, name)| { let source = if is_implicit_formal(name) { ParamSource::Implicit - } else if placeholders.iter().any(|p| p == name) { - ParamSource::PathSegment(name.clone()) } else { - ParamSource::QueryParam(name.clone()) + let positional_slot = non_implicit_seen; + non_implicit_seen += 1; + if placeholders.iter().any(|p| p == name) { + ParamSource::PathSegment(name.clone()) + } else if positional_slot < typed_anon_count { + ParamSource::PathSegment(name.clone()) + } else { + ParamSource::QueryParam(name.clone()) + } }; ParamBinding { index: idx, @@ -233,6 +253,30 @@ fn is_implicit_formal(name: &str) -> bool { matches!(name, "req" | "request" | "state" | "ctx" | "cx" | "headers") } +fn is_typed_anonymous_placeholder(name: &str) -> bool { + matches!( + name, + "u8" | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "f32" + | "f64" + | "bool" + | "char" + | "String" + | "str" + | "Uuid" + ) +} + /// Parse Rust framework verb names (`get` / `post` / `put` / `patch` /// / `delete` / `head` / `options`). Both axum's lowercase routing /// helpers (`get(handler)`) and actix's `web::get()` use the same @@ -869,4 +913,36 @@ mod tests { find_warp_route(tree.root_node(), src, "show").expect("hit"); assert!(path.contains("users")); } + + #[test] + fn warp_typed_anonymous_placeholder_binds_positionally() { + let formals = vec!["id".to_string()]; + let bindings = bind_rust_path_params(&formals, "/users/{u32}"); + assert!(matches!(bindings[0].source, ParamSource::PathSegment(_))); + } + + #[test] + fn warp_multi_typed_anonymous_placeholders_bind_positionally() { + let formals = vec!["user_id".to_string(), "post_slug".to_string()]; + let bindings = + bind_rust_path_params(&formals, "/users/{u32}/posts/{String}"); + assert!(matches!(bindings[0].source, ParamSource::PathSegment(_))); + assert!(matches!(bindings[1].source, ParamSource::PathSegment(_))); + } + + #[test] + fn warp_typed_anonymous_count_caps_positional_binding() { + let formals = vec!["id".to_string(), "extra".to_string()]; + let bindings = bind_rust_path_params(&formals, "/users/{u32}"); + assert!(matches!(bindings[0].source, ParamSource::PathSegment(_))); + assert!(matches!(bindings[1].source, ParamSource::QueryParam(_))); + } + + #[test] + fn warp_implicit_formals_skip_positional_binding() { + let formals = vec!["req".to_string(), "id".to_string()]; + let bindings = bind_rust_path_params(&formals, "/users/{u32}"); + assert!(matches!(bindings[0].source, ParamSource::Implicit)); + assert!(matches!(bindings[1].source, ParamSource::PathSegment(_))); + } } diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 14f740a1..3b465dfe 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -1101,6 +1101,7 @@ fn generate_go_mod() -> String { fn emit_class_method_harness(class: &str, method: &str) -> HarnessSource { let shim = probe_shim(); let go_mod = generate_go_mod(); + let auto_registry = generate_auto_receiver_registry(class); let source = format!( r##"// Nyx dynamic harness — class method (Phase 19 / Track M.1). package main @@ -1118,9 +1119,13 @@ import ( func nyxBuildReceiver(structName string) (reflect.Value, error) {{ // Look up the exported type by name on the entry package. Go's // reflect API does not expose package-level reflection over types - // directly, so the dispatcher uses the package's well-known - // `NyxReceivers` registry the entry file is expected to publish. - if r, ok := entry.NyxReceivers[structName]; ok {{ + // directly, so the dispatcher uses a generated `NyxAutoReceivers` + // registry that the harness ships into the entry package at + // compile time (see `entry/nyx_auto_registry.go`). Real-world + // projects under test never need to hand-declare the registry — + // the auto-generated file references the target type by name and + // the Go compiler enforces the contract. + if r, ok := entry.NyxAutoReceivers[structName]; ok {{ return reflect.ValueOf(r), nil }} return reflect.Value{{}}, fmt.Errorf("class not found: %s", structName) @@ -1180,11 +1185,40 @@ func main() {{ source, filename: "main.go".to_owned(), command: vec!["./nyx_harness".to_owned()], - extra_files: vec![("go.mod".to_owned(), go_mod)], + extra_files: vec![ + ("go.mod".to_owned(), go_mod), + ( + "entry/nyx_auto_registry.go".to_owned(), + auto_registry, + ), + ], entry_subpath: Some("entry/entry.go".to_owned()), } } +/// Generate an `entry/nyx_auto_registry.go` source that publishes a +/// `NyxAutoReceivers` map keyed by the target class name to a +/// zero-constructed instance. The generated file lives in package +/// `entry` so it can reference `class` by bare identifier without +/// re-exporting through the harness package. Compile-time enforcement +/// of the contract is delegated to the Go compiler — if the entry +/// package does not declare `class`, the build fails with a clear +/// `undefined: ` error. +fn generate_auto_receiver_registry(class: &str) -> String { + format!( + r##"// Code generated by Nyx — DO NOT EDIT. +package entry + +// NyxAutoReceivers maps a class name to a zero-constructed instance +// the dynamic harness uses to reflect on methods at runtime. +var NyxAutoReceivers = map[string]interface{{}}{{ + "{class}": {class}{{}}, +}} +"##, + class = class, + ) +} + /// Phase 20 (Track M.2) — message-handler harness for Go. /// /// The entry package is expected to declare a top-level handler diff --git a/tests/class_method_corpus.rs b/tests/class_method_corpus.rs index bfed33d7..4cbc587c 100644 --- a/tests/class_method_corpus.rs +++ b/tests/class_method_corpus.rs @@ -173,8 +173,15 @@ fn class_method_java_emits_reflective_dispatch() { fn class_method_go_uses_reflect_receivers_registry() { let spec = make_spec(Lang::Go); let h = lang::emit(&spec).expect("emit ok"); - assert!(h.source.contains("entry.NyxReceivers")); + assert!(h.source.contains("entry.NyxAutoReceivers")); assert!(h.source.contains("MethodByName")); + let registry = h + .extra_files + .iter() + .find(|(name, _)| name == "entry/nyx_auto_registry.go") + .expect("auto registry emitted"); + assert!(registry.1.contains("NyxAutoReceivers")); + assert!(registry.1.contains("UserService{}")); } #[test] diff --git a/tests/dynamic_fixtures/class_method/go/benign.go b/tests/dynamic_fixtures/class_method/go/benign.go index 1ab5f59a..c4ce63fd 100644 --- a/tests/dynamic_fixtures/class_method/go/benign.go +++ b/tests/dynamic_fixtures/class_method/go/benign.go @@ -9,7 +9,3 @@ func (UserService) Run(input string) string { out, _ := exec.Command("/bin/echo", input).Output() return string(out) } - -var NyxReceivers = map[string]interface{}{ - "UserService": UserService{}, -} diff --git a/tests/dynamic_fixtures/class_method/go/vuln.go b/tests/dynamic_fixtures/class_method/go/vuln.go index fd314bad..a96a96eb 100644 --- a/tests/dynamic_fixtures/class_method/go/vuln.go +++ b/tests/dynamic_fixtures/class_method/go/vuln.go @@ -1,9 +1,9 @@ // Phase 19 (Track M.1) — class-method vuln fixture for Go. // // UserService.Run accepts user input and passes it to `sh -c` so the -// shell interprets it. The fixture publishes its instance through the -// well-known `NyxReceivers` registry the harness uses to construct -// receivers reflectively. +// shell interprets it. The harness compiles in a generated +// `nyx_auto_registry.go` that publishes `UserService{}` so reflection +// works without a hand-rolled registry in the fixture. package entry import "os/exec" @@ -15,7 +15,3 @@ func (UserService) Run(input string) string { out, _ := exec.Command("sh", "-c", "echo "+input).Output() return string(out) } - -var NyxReceivers = map[string]interface{}{ - "UserService": UserService{}, -}