From 0e8c90007896f14869d72cfd7e65aeab540e1acc Mon Sep 17 00:00:00 2001 From: elipeter Date: Sun, 24 May 2026 19:14:50 -0500 Subject: [PATCH] refactor(dynamic): add cross-file route detection for frameworks, enhance test coverage in PHP and Ruby --- .../framework/adapters/php_codeigniter.rs | 111 +++++++- src/dynamic/framework/adapters/php_laravel.rs | 155 +++++++++-- src/dynamic/framework/adapters/php_symfony.rs | 257 ++++++++++++++++-- src/dynamic/framework/adapters/ruby_hanami.rs | 212 +++++++++++---- src/dynamic/framework/mod.rs | 104 ++++++- src/dynamic/lang/js_shared.rs | 84 ++++-- src/dynamic/spec.rs | 97 ++++++- tests/class_method_corpus.rs | 32 +++ .../javascript_recursive_deps/benign.js | 29 ++ .../javascript_recursive_deps/vuln.js | 30 ++ .../typescript_recursive_deps/benign.ts | 29 ++ .../typescript_recursive_deps/vuln.ts | 30 ++ .../codeigniter_config/app/Config/Routes.php | 4 + .../app/Controllers/UserController.php | 10 + .../app/Http/Controllers/UserController.php | 10 + .../laravel_routes/routes/web.php | 5 + .../symfony_yaml/config/routes.yaml | 4 + .../src/Controller/ReportController.php | 12 + .../app/actions/books/show.rb | 11 + .../hanami_config_routes/config/routes.rb | 3 + tests/php_frameworks_corpus.rs | 80 +++++- tests/ruby_frameworks_corpus.rs | 33 ++- 22 files changed, 1208 insertions(+), 134 deletions(-) create mode 100644 tests/dynamic_fixtures/class_method/javascript_recursive_deps/benign.js create mode 100644 tests/dynamic_fixtures/class_method/javascript_recursive_deps/vuln.js create mode 100644 tests/dynamic_fixtures/class_method/typescript_recursive_deps/benign.ts create mode 100644 tests/dynamic_fixtures/class_method/typescript_recursive_deps/vuln.ts create mode 100644 tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Config/Routes.php create mode 100644 tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Controllers/UserController.php create mode 100644 tests/dynamic_fixtures/php_frameworks/laravel_routes/app/Http/Controllers/UserController.php create mode 100644 tests/dynamic_fixtures/php_frameworks/laravel_routes/routes/web.php create mode 100644 tests/dynamic_fixtures/php_frameworks/symfony_yaml/config/routes.yaml create mode 100644 tests/dynamic_fixtures/php_frameworks/symfony_yaml/src/Controller/ReportController.php create mode 100644 tests/dynamic_fixtures/ruby/hanami_config_routes/app/actions/books/show.rb create mode 100644 tests/dynamic_fixtures/ruby/hanami_config_routes/config/routes.rb diff --git a/src/dynamic/framework/adapters/php_codeigniter.rs b/src/dynamic/framework/adapters/php_codeigniter.rs index a5451ab0..b6cffc79 100644 --- a/src/dynamic/framework/adapters/php_codeigniter.rs +++ b/src/dynamic/framework/adapters/php_codeigniter.rs @@ -13,7 +13,9 @@ #[cfg(test)] use crate::dynamic::framework::HttpMethod; -use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape}; +use crate::dynamic::framework::{ + FrameworkAdapter, FrameworkBinding, FrameworkDetectionContext, ProjectFileIndex, RouteShape, +}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; use crate::summary::ssa_summary::SsaFuncSummary; @@ -44,7 +46,7 @@ impl FrameworkAdapter for PhpCodeIgniterAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - detect_codeigniter(summary, None, ast, file_bytes) + detect_codeigniter(summary, None, ast, file_bytes, None) } fn detect_with_context( @@ -54,7 +56,23 @@ impl FrameworkAdapter for PhpCodeIgniterAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - detect_codeigniter(summary, ssa_summary, ast, file_bytes) + detect_codeigniter(summary, ssa_summary, ast, file_bytes, None) + } + + fn detect_with_project_context( + &self, + summary: &FuncSummary, + context: FrameworkDetectionContext<'_>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_codeigniter( + summary, + context.ssa_summary, + ast, + file_bytes, + Some(context.project_files), + ) } } @@ -63,10 +81,8 @@ fn detect_codeigniter( ssa_summary: Option<&SsaFuncSummary>, ast: Node<'_>, file_bytes: &[u8], + project_files: Option<&ProjectFileIndex>, ) -> Option { - if !source_imports_codeigniter(file_bytes) { - return None; - } if !super::typed_receiver_facts_allow( summary, ssa_summary, @@ -78,11 +94,26 @@ fn detect_codeigniter( let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?; let controller = class.and_then(|c| php_class_name(c, file_bytes)); - let (method, path) = find_codeigniter_route(ast, file_bytes, &summary.name, controller)?; + let (method, path, from_project_config) = if let Some((method, path)) = + find_codeigniter_route(ast, file_bytes, &summary.name, controller) + { + (method, path, false) + } else { + let (method, path) = project_files + .and_then(|files| codeigniter_config_route(files, &summary.name, controller))?; + (method, path, true) + }; + + if !source_imports_codeigniter(file_bytes) && !from_project_config { + return None; + } let formals = php_formal_names(func_node, file_bytes); let request_params = bind_php_path_params(&formals, &path); - let middleware = collect_php_middleware(ast, file_bytes); + let mut middleware = collect_php_middleware(ast, file_bytes); + if from_project_config && let Some(files) = project_files { + middleware.extend(codeigniter_config_middleware(files)); + } Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), @@ -94,6 +125,35 @@ fn detect_codeigniter( }) } +fn codeigniter_config_route( + project_files: &ProjectFileIndex, + method_name: &str, + controller: Option<&str>, +) -> Option<(crate::dynamic::framework::HttpMethod, String)> { + let bytes = project_files.get("app/Config/Routes.php")?; + let tree = parse_php(bytes)?; + find_codeigniter_route(tree.root_node(), bytes, method_name, controller) +} + +fn codeigniter_config_middleware( + project_files: &ProjectFileIndex, +) -> Vec { + let Some(bytes) = project_files.get("app/Config/Routes.php") else { + return Vec::new(); + }; + let Some(tree) = parse_php(bytes) else { + return Vec::new(); + }; + collect_php_middleware(tree.root_node(), bytes) +} + +fn parse_php(bytes: &[u8]) -> Option { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP); + parser.set_language(&lang).ok()?; + parser.parse(bytes, None) +} + fn callee_is_codeigniter_route_registration(name: &str) -> bool { let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); matches!(last, "get" | "post" | "put" | "patch" | "delete" | "add") @@ -125,6 +185,15 @@ mod tests { } } + fn summary_at(name: &str, file_path: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + file_path: file_path.into(), + lang: "php".into(), + ..Default::default() + } + } + #[test] fn fires_on_get_route_with_double_colon_callable() { let src: &[u8] = b"get('users/(:num)', 'UserController::show');\nclass UserController extends BaseController {\n public function show($num) { return $num; }\n}\n"; @@ -155,6 +224,32 @@ mod tests { assert_eq!(binding.route.unwrap().method, HttpMethod::POST); } + #[test] + fn resolves_project_config_routes_file() { + let src: &[u8] = b"get('users/(:num)', 'UserController::show');\n".to_vec(), + ); + let context = FrameworkDetectionContext { + ssa_summary: None, + project_files: &project_files, + }; + let binding = PhpCodeIgniterAdapter + .detect_with_project_context( + &summary_at("show", "/tmp/app/app/Controllers/UserController.php"), + context, + tree.root_node(), + src, + ) + .expect("binding from app/Config/Routes.php"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "users/(:num)"); + } + #[test] fn skips_when_codeigniter_not_imported() { let src: &[u8] = b"get('users/(:num)', 'UserController::show');\n"; diff --git a/src/dynamic/framework/adapters/php_laravel.rs b/src/dynamic/framework/adapters/php_laravel.rs index 30d46099..a0767077 100644 --- a/src/dynamic/framework/adapters/php_laravel.rs +++ b/src/dynamic/framework/adapters/php_laravel.rs @@ -14,7 +14,9 @@ #[cfg(test)] use crate::dynamic::framework::HttpMethod; -use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::dynamic::framework::{ + FrameworkAdapter, FrameworkBinding, FrameworkDetectionContext, ProjectFileIndex, RouteShape, +}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; use crate::symbol::Lang; @@ -44,27 +46,98 @@ impl FrameworkAdapter for PhpLaravelAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - if !source_imports_laravel(file_bytes) { - return None; - } - let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?; - let controller = class.and_then(|c| php_class_name(c, file_bytes)); - - let route = find_laravel_static_route_shape(ast, file_bytes, &summary.name, controller)?; - - let formals = php_formal_names(func_node, file_bytes); - let request_params = bind_php_path_params(&formals, &route.path); - let middleware = collect_php_middleware(ast, file_bytes); - - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::HttpRoute, - route: Some(route), - request_params, - response_writer: None, - middleware, - }) + detect_laravel(summary, ast, file_bytes, None) } + + fn detect_with_project_context( + &self, + summary: &FuncSummary, + context: FrameworkDetectionContext<'_>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_laravel(summary, ast, file_bytes, Some(context.project_files)) + } +} + +fn detect_laravel( + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + project_files: Option<&ProjectFileIndex>, +) -> Option { + let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?; + let controller = class.and_then(|c| php_class_name(c, file_bytes)); + + let (route, from_project_config) = if let Some(route) = + find_laravel_static_route_shape(ast, file_bytes, &summary.name, controller) + { + (route, false) + } else { + ( + project_files + .and_then(|files| laravel_config_route_shape(files, &summary.name, controller))?, + true, + ) + }; + + if !source_imports_laravel(file_bytes) && !from_project_config { + return None; + } + + let formals = php_formal_names(func_node, file_bytes); + let request_params = bind_php_path_params(&formals, &route.path); + let mut middleware = collect_php_middleware(ast, file_bytes); + if from_project_config && let Some(files) = project_files { + middleware.extend(laravel_config_middleware(files)); + } + + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(route), + request_params, + response_writer: None, + middleware, + }) +} + +fn laravel_config_route_shape( + project_files: &ProjectFileIndex, + method_name: &str, + controller: Option<&str>, +) -> Option { + for rel in ["routes/web.php", "routes/api.php"] { + if let Some(bytes) = project_files.get(rel) + && let Some(tree) = parse_php(bytes) + && let Some(route) = + find_laravel_static_route_shape(tree.root_node(), bytes, method_name, controller) + { + return Some(route); + } + } + None +} + +fn laravel_config_middleware( + project_files: &ProjectFileIndex, +) -> Vec { + let mut out = Vec::new(); + for rel in ["routes/web.php", "routes/api.php"] { + if let Some(bytes) = project_files.get(rel) + && let Some(tree) = parse_php(bytes) + { + out.extend(collect_php_middleware(tree.root_node(), bytes)); + } + } + out +} + +fn parse_php(bytes: &[u8]) -> Option { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP); + parser.set_language(&lang).ok()?; + parser.parse(bytes, None) } #[cfg(test)] @@ -87,6 +160,15 @@ mod tests { } } + fn summary_at(name: &str, file_path: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + file_path: file_path.into(), + lang: "php".into(), + ..Default::default() + } + } + #[test] fn fires_on_route_get_with_controller_method() { let src: &[u8] = b"middleware('auth');\n".to_vec(), + ); + let context = FrameworkDetectionContext { + ssa_summary: None, + project_files: &project_files, + }; + let binding = PhpLaravelAdapter + .detect_with_project_context( + &summary_at("show", "/tmp/app/app/Http/Controllers/UserController.php"), + context, + tree.root_node(), + src, + ) + .expect("binding from routes/web.php"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/users/{id}"); + assert!( + binding.middleware.iter().any(|m| m.name == "auth"), + "expected auth middleware from routes/web.php, got {:?}", + binding.middleware + ); + } + #[test] fn preserves_match_route_methods() { let src: &[u8] = b"90% of modern Symfony 5/6/7 controller -//! declarations and is the only path the harness needs to bind a -//! single route inside a single source file. YAML lookup belongs to -//! a later phase once the framework adapter trait gains access to -//! the project-level config file list. +//! The adapter also recognises project `config/routes.yaml` / +//! `config/routes.yml` entries when detection receives a project-file +//! context. -use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; +use crate::dynamic::framework::{ + FrameworkAdapter, FrameworkBinding, FrameworkDetectionContext, HttpMethod, ProjectFileIndex, + RouteShape, +}; use crate::evidence::EntryKind; use crate::summary::FuncSummary; use crate::symbol::Lang; @@ -20,7 +20,8 @@ use tree_sitter::Node; use super::php_routes::{ bind_php_path_params, collect_php_middleware, find_php_function, first_php_string_arg, - iter_php_attributes, methods_named_arg, php_formal_names, source_imports_symfony, + iter_php_attributes, methods_named_arg, php_class_name, php_formal_names, + source_imports_symfony, }; pub struct PhpSymfonyAdapter; @@ -72,28 +73,185 @@ impl FrameworkAdapter for PhpSymfonyAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - if !source_imports_symfony(file_bytes) { - return None; - } - let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?; - let (http_method, method_path) = route_attribute_shape(func_node, file_bytes)?; - let class_prefix = class - .and_then(|c| route_attribute_shape(c, file_bytes)) - .map(|(_, p)| p) - .unwrap_or_default(); - let path = join_route_path(&class_prefix, &method_path); - let formals = php_formal_names(func_node, file_bytes); - let request_params = bind_php_path_params(&formals, &path); - let middleware = collect_php_middleware(ast, file_bytes); + detect_symfony(summary, ast, file_bytes, None) + } - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::HttpRoute, - route: Some(RouteShape::single(http_method, path)), - request_params, - response_writer: None, - middleware, - }) + fn detect_with_project_context( + &self, + summary: &FuncSummary, + context: FrameworkDetectionContext<'_>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_symfony(summary, ast, file_bytes, Some(context.project_files)) + } +} + +fn detect_symfony( + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + project_files: Option<&ProjectFileIndex>, +) -> Option { + let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?; + let controller = class.and_then(|c| php_class_name(c, file_bytes)); + let (route, from_project_config) = + if let Some((http_method, method_path)) = route_attribute_shape(func_node, file_bytes) { + let class_prefix = class + .and_then(|c| route_attribute_shape(c, file_bytes)) + .map(|(_, p)| p) + .unwrap_or_default(); + ( + Some(RouteShape::single( + http_method, + join_route_path(&class_prefix, &method_path), + )), + false, + ) + } else { + ( + project_files.and_then(|files| yaml_route_shape(files, &summary.name, controller)), + true, + ) + }; + + let route = route?; + if !source_imports_symfony(file_bytes) && !from_project_config { + return None; + } + + let formals = php_formal_names(func_node, file_bytes); + let request_params = bind_php_path_params(&formals, &route.path); + let middleware = collect_php_middleware(ast, file_bytes); + + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(route), + request_params, + response_writer: None, + middleware, + }) +} + +fn yaml_route_shape( + project_files: &ProjectFileIndex, + method_name: &str, + controller: Option<&str>, +) -> Option { + for rel in ["config/routes.yaml", "config/routes.yml"] { + if let Some(bytes) = project_files.get(rel) + && let Some(shape) = parse_symfony_yaml_routes(bytes, method_name, controller) + { + return Some(shape); + } + } + None +} + +#[derive(Default)] +struct SymfonyYamlRoute { + path: Option, + controller: Option, + method: Option, +} + +fn parse_symfony_yaml_routes( + bytes: &[u8], + method_name: &str, + class_name: Option<&str>, +) -> Option { + let text = std::str::from_utf8(bytes).ok()?; + let mut current: Option = None; + for raw in text.lines() { + let line = raw.trim_end(); + let trim = line.trim_start(); + if trim.is_empty() || trim.starts_with('#') { + continue; + } + let indent = line.len().saturating_sub(trim.len()); + if indent == 0 && trim.ends_with(':') { + if let Some(shape) = finish_yaml_route(current.take(), method_name, class_name) { + return Some(shape); + } + current = Some(SymfonyYamlRoute::default()); + continue; + } + let Some(route) = current.as_mut() else { + continue; + }; + let Some((key, value)) = trim.split_once(':') else { + continue; + }; + let value = yaml_scalar(value); + match key.trim() { + "path" => route.path = Some(value), + "controller" | "_controller" => route.controller = Some(value), + "methods" => route.method = yaml_method(&value), + "defaults" => { + if let Some(controller) = inline_yaml_value(&value, "_controller") { + route.controller = Some(controller); + } + } + _ => {} + } + } + finish_yaml_route(current, method_name, class_name) +} + +fn finish_yaml_route( + route: Option, + method_name: &str, + class_name: Option<&str>, +) -> Option { + let route = route?; + let path = route.path?; + let controller = route.controller?; + if !controller_matches(&controller, method_name, class_name) { + return None; + } + Some(RouteShape::single( + route.method.unwrap_or(HttpMethod::GET), + path, + )) +} + +fn yaml_scalar(value: &str) -> String { + value.trim().trim_matches('"').trim_matches('\'').to_owned() +} + +fn inline_yaml_value(value: &str, key: &str) -> Option { + let trimmed = value.trim().trim_start_matches('{').trim_end_matches('}'); + for part in trimmed.split(',') { + let (k, v) = part.split_once(':')?; + if k.trim() == key { + return Some(yaml_scalar(v)); + } + } + None +} + +fn yaml_method(value: &str) -> Option { + for raw in value.trim_matches('[').trim_matches(']').split([',', ' ']) { + let token = raw.trim().trim_matches('"').trim_matches('\''); + if let Some(method) = HttpMethod::from_ident(token) { + return Some(method); + } + } + None +} + +fn controller_matches(controller: &str, method_name: &str, class_name: Option<&str>) -> bool { + let controller = controller.trim(); + let Some((class, method)) = controller.rsplit_once("::") else { + return false; + }; + if method != method_name { + return false; + } + match class_name { + Some(expected) => class.rsplit('\\').next().unwrap_or(class) == expected, + None => true, } } @@ -117,6 +275,15 @@ mod tests { } } + fn summary_at(name: &str, file_path: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + file_path: file_path.into(), + lang: "php".into(), + ..Default::default() + } + } + #[test] fn fires_on_method_route_attribute_with_class_prefix() { let src: &[u8] = b", bytes: &[u8]) -> bool { /// Resolve the route metadata for `class_name`. Tries the inline /// Hanami v2 routes DSL first (`get "/run", to: "RunAction"` inside a /// `Hanami::Routes` / `routes do` block that co-exists with the -/// action class in the same file), then the synthetic -/// `# nyx-route: ` comment fixtures rely on, then -/// finally a `(GET, fallback_path)` default. -/// -/// Cross-file routes resolution (`config/routes.rb` + `app/actions//.rb`) -/// still needs a project-level file index on the adapter trait — -/// `FrameworkAdapter::detect` only sees one file at a time. +/// action class in the same file), then project `config/routes.rb`, +/// then the synthetic `# nyx-route: ` comment fixtures +/// rely on, then finally a `(GET, fallback_path)` default. fn route_for_class( file_bytes: &[u8], class_name: &str, fallback_path: &str, + entry_file: &str, + project_files: Option<&ProjectFileIndex>, ) -> (HttpMethod, String) { - if let Some(found) = parse_inline_route(file_bytes, class_name) { + let targets = route_targets(class_name, entry_file); + if let Some(found) = parse_route_source(file_bytes, &targets) { + return found; + } + if let Some(routes) = project_files.and_then(|files| files.get("config/routes.rb")) + && let Some(found) = parse_route_source(routes, &targets) + { return found; } if let Some(found) = pinned_comment_route(file_bytes) { @@ -70,29 +77,22 @@ fn pinned_comment_route(file_bytes: &[u8]) -> Option<(HttpMethod, String)> { None } -/// Parse the Hanami v2 routes DSL when the routes file and the action -/// class co-exist in one file. Recognises lines of the form +/// Parse the Hanami v2 routes DSL. Recognises lines of the form /// ` "", to: ""` (or single-quoted variants) and -/// matches `` against `class_name` either by exact match or by -/// its `snake_case` form (Hanami's container-key convention, -/// e.g. `to: "actions.run_action"`). -fn parse_inline_route(file_bytes: &[u8], class_name: &str) -> Option<(HttpMethod, String)> { +/// matches `` against the class name, its `snake_case` form, +/// or the `app/actions//.rb` container key when present. +fn parse_route_source(file_bytes: &[u8], targets: &[String]) -> Option<(HttpMethod, String)> { let text = std::str::from_utf8(file_bytes).ok()?; - let snake = camel_to_snake(class_name); for raw_line in text.lines() { let line = raw_line.trim_start(); - if let Some(parsed) = parse_route_line(line, class_name, &snake) { + if let Some(parsed) = parse_route_line(line, targets) { return Some(parsed); } } None } -fn parse_route_line( - line: &str, - class_orig: &str, - class_snake: &str, -) -> Option<(HttpMethod, String)> { +fn parse_route_line(line: &str, targets: &[String]) -> Option<(HttpMethod, String)> { let (verb_tok, after) = line.split_once(char::is_whitespace)?; let method = HttpMethod::from_ident(verb_tok)?; let after = after.trim_start(); @@ -100,13 +100,60 @@ fn parse_route_line( let to_idx = rest.find("to:")?; let after_to = rest[to_idx + 3..].trim_start(); let (target, _) = parse_quoted(after_to)?; - let target_last = target.rsplit_once('.').map(|(_, s)| s).unwrap_or(&target); - if target_last == class_orig || target_last == class_snake { + if target_matches(&target, targets) { return Some((method, path)); } None } +fn route_targets(class_name: &str, entry_file: &str) -> Vec { + let mut out = Vec::new(); + push_unique(&mut out, class_name.to_owned()); + push_unique(&mut out, camel_to_snake(class_name)); + if class_name.contains("::") { + let dotted = class_name.replace("::", "."); + push_unique(&mut out, dotted.clone()); + let snake_dotted = dotted + .split('.') + .map(camel_to_snake) + .collect::>() + .join("."); + push_unique(&mut out, snake_dotted); + } + if let Some(key) = hanami_action_key_from_path(entry_file) { + push_unique(&mut out, key); + } + out +} + +fn push_unique(out: &mut Vec, value: String) { + if !value.is_empty() && !out.iter().any(|existing| existing == &value) { + out.push(value); + } +} + +fn hanami_action_key_from_path(entry_file: &str) -> Option { + let normalized = entry_file.replace('\\', "/"); + let marker = "app/actions/"; + let rel = normalized + .split_once(marker) + .map(|(_, rest)| rest) + .or_else(|| normalized.strip_prefix(marker))?; + let stem = rel.strip_suffix(".rb").unwrap_or(rel); + if stem.is_empty() { + return None; + } + Some(stem.replace('/', ".")) +} + +fn target_matches(target: &str, candidates: &[String]) -> bool { + let normalized = target.replace("::", "."); + let target_last = normalized.rsplit('.').next().unwrap_or(normalized.as_str()); + candidates.iter().any(|candidate| { + normalized == *candidate || target_last == candidate || normalized.ends_with(candidate) + }) +} + fn parse_quoted(s: &str) -> Option<(String, &str)> { let quote = match s.as_bytes().first() { Some(b'"') => '"', @@ -164,31 +211,56 @@ impl FrameworkAdapter for RubyHanamiAdapter { ast: Node<'_>, file_bytes: &[u8], ) -> Option { - if summary.name != "call" { - return None; - } - if !source_imports_hanami(file_bytes) { - return None; - } - let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; - if !class_is_hanami_action(class, file_bytes) { - return None; - } - let cls_name = class_name(class, file_bytes).unwrap_or("Entry"); - let default = hanami_default_path(cls_name); - let (http_method, path) = route_for_class(file_bytes, cls_name, &default); - let formals = method_formal_names(method, file_bytes); - let request_params = bind_path_params(&formals, &path); - let middleware = collect_ruby_middleware(ast, file_bytes); - Some(FrameworkBinding { - adapter: ADAPTER_NAME.to_owned(), - kind: EntryKind::HttpRoute, - route: Some(RouteShape::single(http_method, path)), - request_params, - response_writer: None, - middleware, - }) + detect_hanami(summary, ast, file_bytes, None) } + + fn detect_with_project_context( + &self, + summary: &FuncSummary, + context: FrameworkDetectionContext<'_>, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_hanami(summary, ast, file_bytes, Some(context.project_files)) + } +} + +fn detect_hanami( + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + project_files: Option<&ProjectFileIndex>, +) -> Option { + if summary.name != "call" { + return None; + } + if !source_imports_hanami(file_bytes) { + return None; + } + let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; + if !class_is_hanami_action(class, file_bytes) { + return None; + } + let cls_name = class_name(class, file_bytes).unwrap_or("Entry"); + let default = hanami_default_path(cls_name); + let (http_method, path) = route_for_class( + file_bytes, + cls_name, + &default, + &summary.file_path, + project_files, + ); + let formals = method_formal_names(method, file_bytes); + let request_params = bind_path_params(&formals, &path); + let middleware = collect_ruby_middleware(ast, file_bytes); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape::single(http_method, path)), + request_params, + response_writer: None, + middleware, + }) } #[cfg(test)] @@ -211,6 +283,15 @@ mod tests { } } + fn summary_at(name: &str, file_path: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + file_path: file_path.into(), + lang: "ruby".into(), + ..Default::default() + } + } + #[test] fn fires_on_hanami_action_subclass() { let src: &[u8] = @@ -250,6 +331,33 @@ mod tests { assert_eq!(route.path, "/save"); } + #[test] + fn resolves_cross_file_config_routes() { + let src: &[u8] = + b"require 'hanami/action'\nmodule Books\n class Show\n include Hanami::Action\n def call(req)\n 'ok'\n end\n end\nend\n"; + let tree = parse(src); + let mut project_files = ProjectFileIndex::new(); + project_files.insert( + "config/routes.rb", + b"Hanami.app.routes do\n get '/books/:id', to: 'books.show'\nend\n".to_vec(), + ); + let context = FrameworkDetectionContext { + ssa_summary: None, + project_files: &project_files, + }; + let binding = RubyHanamiAdapter + .detect_with_project_context( + &summary_at("call", "/tmp/shop/app/actions/books/show.rb"), + context, + tree.root_node(), + src, + ) + .expect("binding from config/routes.rb"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/books/:id"); + } + #[test] fn binds_path_placeholder() { let src: &[u8] = b"# nyx-route: GET /u/:id\nrequire 'hanami/action'\nclass Show < Hanami::Action\n def call(req, id)\n id\n end\nend\n"; diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index 3548eebe..7ea888c0 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -23,6 +23,71 @@ use crate::summary::FuncSummary; use crate::summary::ssa_summary::SsaFuncSummary; use crate::symbol::Lang; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::Path; + +/// Small project-file index exposed to framework adapters that need +/// config files outside the entry source. +/// +/// Keys are project-relative paths using `/` separators, for example +/// `config/routes.rb` or `routes/web.php`. Values are raw file bytes. +/// The index is intentionally narrow: callers decide which config +/// files to load so adapter dispatch does not walk the whole project. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ProjectFileIndex { + files: BTreeMap>, +} + +impl ProjectFileIndex { + /// Create an empty file index. + pub fn new() -> Self { + Self::default() + } + + /// Build an index from a project root and a fixed list of + /// project-relative paths. Missing or unreadable files are skipped. + pub fn from_root(root: &Path, rel_paths: &[&str]) -> Self { + let mut index = Self::new(); + for rel in rel_paths { + let path = root.join(rel); + if let Ok(bytes) = std::fs::read(&path) { + index.insert(*rel, bytes); + } + } + index + } + + /// Insert or replace a project-relative file. + pub fn insert(&mut self, rel_path: impl Into, bytes: impl Into>) { + self.files + .insert(normalize_project_rel(rel_path), bytes.into()); + } + + /// Return bytes for `rel_path` when present. + pub fn get(&self, rel_path: &str) -> Option<&[u8]> { + self.files + .get(&normalize_project_rel(rel_path)) + .map(Vec::as_slice) + } + + /// True when the index has no files. + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +fn normalize_project_rel(rel_path: impl Into) -> String { + rel_path.into().replace('\\', "/") +} + +/// Extra context supplied to framework adapters during detection. +#[derive(Debug, Clone, Copy)] +pub struct FrameworkDetectionContext<'a> { + /// Optional SSA summary for receiver-type-aware narrowing. + pub ssa_summary: Option<&'a SsaFuncSummary>, + /// Project config files known to the caller. + pub project_files: &'a ProjectFileIndex, +} /// HTTP method recognised by route bindings. Mirrors /// [`crate::entry_points::HttpMethod`] but is re-declared here so the @@ -237,6 +302,21 @@ pub trait FrameworkAdapter: Sync { ) -> Option { self.detect(summary, ast, file_bytes) } + + /// Detection variant with all optional framework context bundled + /// into a single struct. Adapters that need project-level route + /// files override this method; the default delegates to the + /// SSA-aware legacy method so existing adapters keep their current + /// behaviour. + fn detect_with_project_context( + &self, + summary: &FuncSummary, + context: FrameworkDetectionContext<'_>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + self.detect_with_context(summary, context.ssa_summary, ast, file_bytes) + } } /// Walk every adapter registered for `lang` in registration order @@ -265,6 +345,26 @@ pub fn detect_binding_with_context( ast: tree_sitter::Node<'_>, file_bytes: &[u8], lang: Lang, +) -> Option { + let project_files = ProjectFileIndex::new(); + let context = FrameworkDetectionContext { + ssa_summary, + project_files: &project_files, + }; + detect_binding_with_project_context(summary, context, ast, file_bytes, lang) +} + +/// Full-context sibling of [`detect_binding_with_context`]. +/// +/// This is the entry point used by spec derivation once it has a +/// project root available. Test callers and single-file callers can +/// keep using [`detect_binding`] / [`detect_binding_with_context`]. +pub fn detect_binding_with_project_context( + summary: &FuncSummary, + context: FrameworkDetectionContext<'_>, + ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + lang: Lang, ) -> Option { for adapter in registry::adapters_for(lang) { debug_assert_eq!( @@ -273,7 +373,9 @@ pub fn detect_binding_with_context( "adapter '{}' registered under wrong lang", adapter.name() ); - if let Some(binding) = adapter.detect_with_context(summary, ssa_summary, ast, file_bytes) { + if let Some(binding) = + adapter.detect_with_project_context(summary, context, ast, file_bytes) + { return Some(binding); } } diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 516dd4ef..24f0b4ab 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -718,8 +718,8 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result part.replace(/\/\*.*?\*\//g, '').replace(/\/\/.*$/g, '').trim()) + .map((part) => part.replace(/^(\.\.\.)/, '').split('=')[0].trim()) + .filter(Boolean); +}} + +function _nyxClassNameFromParam(paramName) {{ + const cleaned = String(paramName || '') + .replace(/^[^A-Za-z_$]+/, '') + .replace(/[^A-Za-z0-9_$]+(.)/g, (_m, ch) => String(ch).toUpperCase()); + if (!cleaned) return ''; + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); +}} + +function _nyxKnownMock(paramName) {{ + const lc = String(paramName || '').toLowerCase(); + if (lc.includes('http') || lc.includes('client')) return new MockHttpClient(); + if (lc.includes('database') || lc.includes('db')) return new MockDatabaseConnection(); + if (lc.includes('logger') || lc.includes('log')) return new MockLogger(); + return null; +}} + +function _nyxBuildDependency(paramName, depth, seen) {{ + const depName = _nyxClassNameFromParam(paramName); + const Dep = _nyxExportedClass(depName); + if (typeof Dep === 'function') {{ + const built = _nyxBuildReceiver(Dep, depth - 1, new Set(seen)); + if (built != null) return built; + }} + return _nyxKnownMock(paramName); +}} + +function _nyxBuildReceiver(Cls, depth = 3, seen = new Set()) {{ + if (typeof Cls !== 'function') return null; + const clsName = Cls.name || ''; + if (depth < 0 || seen.has(clsName)) return null; + seen.add(clsName); + const params = _nyxConstructorParams(Cls); + if (params.length > 0) {{ + const deps = params.map((name) => _nyxBuildDependency(name, depth, seen)); + if (deps.every((dep) => dep != null)) {{ + try {{ return new Cls(...deps); }} catch (_e) {{}} + }} + }} + try {{ return new Cls(); }} catch (_e) {{}} + try {{ return new Cls(new MockHttpClient(), new MockDatabaseConnection(), new MockLogger()); }} catch (_e2) {{}} + try {{ return new Cls(new MockDatabaseConnection()); }} catch (_e3) {{}} + try {{ return new Cls(new MockHttpClient()); }} catch (_e4) {{}} + try {{ return new Cls(new MockLogger()); }} catch (_e5) {{}} + return null; +}} + +const _instance = _nyxBuildReceiver(_Cls, 3); if (_instance == null) {{ process.stderr.write('NYX_CLASS_CTOR_FAILED: ' + {class:?} + '\n'); process.exit(78); diff --git a/src/dynamic/spec.rs b/src/dynamic/spec.rs index 93d62562..95e391f9 100644 --- a/src/dynamic/spec.rs +++ b/src/dynamic/spec.rs @@ -20,7 +20,7 @@ use crate::callgraph::{CallGraph, CallGraphAnalysis}; use crate::commands::scan::Diag; use crate::dynamic::corpus::CORPUS_VERSION; -use crate::dynamic::framework::FrameworkBinding; +use crate::dynamic::framework::{FrameworkBinding, FrameworkDetectionContext, ProjectFileIndex}; use crate::dynamic::stubs::StubKind; use crate::evidence::{Confidence, FlowStepKind, UnsupportedReason}; use crate::labels::Cap; @@ -28,7 +28,7 @@ use crate::summary::{FuncSummary, GlobalSummaries}; use crate::symbol::{FuncKey, Lang}; use serde::{Deserialize, Serialize}; use std::collections::{HashSet, VecDeque}; -use std::path::Path; +use std::path::{Path, PathBuf}; /// Re-export of the always-present [`crate::evidence::SpecDerivationStrategy`]. /// @@ -1241,9 +1241,14 @@ fn attach_framework_binding(spec: &mut HarnessSpec, summaries: Option<&GlobalSum let summary_ref = resolved.unwrap_or(&synthetic); let ssa_ref = summaries .and_then(|gs| find_ssa_summary_by_path(gs, spec.lang, &spec.entry_name, &spec.entry_file)); - if let Some(binding) = crate::dynamic::framework::detect_binding_with_context( + let project_files = framework_project_files_for_entry(&spec.entry_file, spec.lang); + let context = FrameworkDetectionContext { + ssa_summary: ssa_ref, + project_files: &project_files, + }; + if let Some(binding) = crate::dynamic::framework::detect_binding_with_project_context( summary_ref, - ssa_ref, + context, tree.root_node(), &bytes, spec.lang, @@ -1252,6 +1257,43 @@ fn attach_framework_binding(spec: &mut HarnessSpec, summaries: Option<&GlobalSum } } +fn framework_project_files_for_entry(entry_file: &str, lang: Lang) -> ProjectFileIndex { + let Some(root) = infer_framework_project_root(Path::new(entry_file), lang) else { + return ProjectFileIndex::new(); + }; + let rel_paths: &[&str] = match lang { + Lang::Ruby => &["config/routes.rb"], + Lang::Php => &[ + "config/routes.yaml", + "config/routes.yml", + "routes/web.php", + "routes/api.php", + "app/Config/Routes.php", + ], + _ => &[], + }; + ProjectFileIndex::from_root(&root, rel_paths) +} + +fn infer_framework_project_root(entry_path: &Path, lang: Lang) -> Option { + let dirs: &[&str] = match lang { + Lang::Ruby => &["app"], + Lang::Php => &["src", "app"], + _ => &[], + }; + for ancestor in entry_path.ancestors() { + let Some(name) = ancestor.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if dirs.contains(&name) + && let Some(parent) = ancestor.parent() + { + return Some(parent.to_path_buf()); + } + } + entry_path.parent().map(|p| p.to_path_buf()) +} + /// Phase 18 (Track M.0) — apply a resolved [`FrameworkBinding`] onto /// the spec. Carved out of [`attach_framework_binding`] so the /// stamping branch (Phase 18 data-bearing-variant propagation + @@ -2227,6 +2269,53 @@ mod tests { assert_eq!(spec_no_summaries.spec_hash, spec_with_summaries.spec_hash); } + #[test] + fn attach_framework_binding_reads_project_route_config() { + use crate::dynamic::framework::HttpMethod; + use std::fs; + use std::io::Write; + + let dir = tempfile::tempdir().expect("tempdir"); + let action_dir = dir.path().join("app/actions/books"); + fs::create_dir_all(&action_dir).expect("action dir"); + let config_dir = dir.path().join("config"); + fs::create_dir_all(&config_dir).expect("config dir"); + let action = action_dir.join("show.rb"); + fs::File::create(&action) + .expect("action create") + .write_all( + b"require 'hanami/action'\nmodule Books\n class Show\n include Hanami::Action\n def call(req)\n system(req.params[:cmd])\n end\n end\nend\n", + ) + .expect("action write"); + fs::File::create(config_dir.join("routes.rb")) + .expect("routes create") + .write_all(b"Hanami.app.routes do\n post '/books/:id', to: 'books.show'\nend\n") + .expect("routes write"); + let entry_file = action.to_string_lossy().into_owned(); + + let ev = Evidence { + flow_steps: vec![source_step(&entry_file, "call"), sink_step(&entry_file)], + sink_caps: Cap::SHELL_ESCAPE.bits(), + ..Default::default() + }; + let diag = crate::commands::scan::Diag { + id: "rb.cmdi.system".into(), + path: entry_file.clone(), + line: 5, + confidence: Some(Confidence::High), + evidence: Some(ev), + ..Default::default() + }; + + let spec = HarnessSpec::from_finding_full(&diag, false, None, None) + .expect("spec derives and attaches framework config"); + let binding = spec.framework.expect("hanami binding"); + assert_eq!(binding.adapter, "ruby-hanami"); + let route = binding.route.expect("route"); + assert_eq!(route.method, HttpMethod::POST); + assert_eq!(route.path, "/books/:id"); + } + /// Phase 18 (Track M.0) deferred-fix: when a [`FrameworkBinding`] /// carries one of the seven data-bearing variants /// (`ClassMethod`, `MessageHandler`, …), the spec stamping path diff --git a/tests/class_method_corpus.rs b/tests/class_method_corpus.rs index b0849ae7..8ddadec9 100644 --- a/tests/class_method_corpus.rs +++ b/tests/class_method_corpus.rs @@ -166,6 +166,16 @@ fn class_method_python_dispatch_reads_payload_and_invokes_method() { assert!(h.source.contains("_nyx_resolve_annotation")); } +#[test] +fn class_method_js_dispatch_builds_recursive_receiver() { + let spec = make_spec(Lang::JavaScript); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("_nyxBuildReceiver(_Cls, 3)")); + assert!(h.source.contains("_nyxConstructorParams")); + assert!(h.source.contains("_nyxExportedClass")); + assert!(h.source.contains("depth = 3")); +} + #[test] fn class_method_java_emits_reflective_dispatch() { let spec = make_spec(Lang::Java); @@ -285,6 +295,17 @@ mod e2e_phase_19 { cap: Cap::CODE_EXEC, bins: &["node"], }, + Case { + lang: Lang::JavaScript, + fixture_dir: "javascript_recursive_deps", + vuln_file: "vuln.js", + benign_file: "benign.js", + vuln_class: "UserService", + benign_class: "UserService", + method: "run", + cap: Cap::CODE_EXEC, + bins: &["node"], + }, Case { lang: Lang::TypeScript, fixture_dir: "typescript", @@ -296,6 +317,17 @@ mod e2e_phase_19 { cap: Cap::CODE_EXEC, bins: &["node"], }, + Case { + lang: Lang::TypeScript, + fixture_dir: "typescript_recursive_deps", + vuln_file: "vuln.ts", + benign_file: "benign.ts", + vuln_class: "UserService", + benign_class: "UserService", + method: "run", + cap: Cap::CODE_EXEC, + bins: &["node"], + }, Case { lang: Lang::Php, fixture_dir: "php", diff --git a/tests/dynamic_fixtures/class_method/javascript_recursive_deps/benign.js b/tests/dynamic_fixtures/class_method/javascript_recursive_deps/benign.js new file mode 100644 index 00000000..af066ca0 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/javascript_recursive_deps/benign.js @@ -0,0 +1,29 @@ +'use strict'; + +class ShellRunner { + run(_command) { + return 'safe'; + } +} + +class UserRepository { + constructor(shellRunner) { + this.shellRunner = shellRunner; + } + + find(input) { + return this.shellRunner.run(input); + } +} + +class UserService { + constructor(userRepository) { + this.userRepository = userRepository; + } + + run(input) { + return this.userRepository.find(input); + } +} + +module.exports = { UserService, UserRepository, ShellRunner }; diff --git a/tests/dynamic_fixtures/class_method/javascript_recursive_deps/vuln.js b/tests/dynamic_fixtures/class_method/javascript_recursive_deps/vuln.js new file mode 100644 index 00000000..5ab899bb --- /dev/null +++ b/tests/dynamic_fixtures/class_method/javascript_recursive_deps/vuln.js @@ -0,0 +1,30 @@ +'use strict'; +const { execSync } = require('child_process'); + +class ShellRunner { + run(command) { + return execSync('true ' + command).toString(); + } +} + +class UserRepository { + constructor(shellRunner) { + this.shellRunner = shellRunner; + } + + find(input) { + return this.shellRunner.run(input); + } +} + +class UserService { + constructor(userRepository) { + this.userRepository = userRepository; + } + + run(input) { + return this.userRepository.find(input); + } +} + +module.exports = { UserService, UserRepository, ShellRunner }; diff --git a/tests/dynamic_fixtures/class_method/typescript_recursive_deps/benign.ts b/tests/dynamic_fixtures/class_method/typescript_recursive_deps/benign.ts new file mode 100644 index 00000000..af066ca0 --- /dev/null +++ b/tests/dynamic_fixtures/class_method/typescript_recursive_deps/benign.ts @@ -0,0 +1,29 @@ +'use strict'; + +class ShellRunner { + run(_command) { + return 'safe'; + } +} + +class UserRepository { + constructor(shellRunner) { + this.shellRunner = shellRunner; + } + + find(input) { + return this.shellRunner.run(input); + } +} + +class UserService { + constructor(userRepository) { + this.userRepository = userRepository; + } + + run(input) { + return this.userRepository.find(input); + } +} + +module.exports = { UserService, UserRepository, ShellRunner }; diff --git a/tests/dynamic_fixtures/class_method/typescript_recursive_deps/vuln.ts b/tests/dynamic_fixtures/class_method/typescript_recursive_deps/vuln.ts new file mode 100644 index 00000000..5ab899bb --- /dev/null +++ b/tests/dynamic_fixtures/class_method/typescript_recursive_deps/vuln.ts @@ -0,0 +1,30 @@ +'use strict'; +const { execSync } = require('child_process'); + +class ShellRunner { + run(command) { + return execSync('true ' + command).toString(); + } +} + +class UserRepository { + constructor(shellRunner) { + this.shellRunner = shellRunner; + } + + find(input) { + return this.shellRunner.run(input); + } +} + +class UserService { + constructor(userRepository) { + this.userRepository = userRepository; + } + + run(input) { + return this.userRepository.find(input); + } +} + +module.exports = { UserService, UserRepository, ShellRunner }; diff --git a/tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Config/Routes.php b/tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Config/Routes.php new file mode 100644 index 00000000..79fc2ffe --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Config/Routes.php @@ -0,0 +1,4 @@ +get('users/(:num)', 'UserController::show'); diff --git a/tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Controllers/UserController.php b/tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Controllers/UserController.php new file mode 100644 index 00000000..3bd897ee --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Controllers/UserController.php @@ -0,0 +1,10 @@ +middleware('auth'); diff --git a/tests/dynamic_fixtures/php_frameworks/symfony_yaml/config/routes.yaml b/tests/dynamic_fixtures/php_frameworks/symfony_yaml/config/routes.yaml new file mode 100644 index 00000000..ddc714e7 --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/symfony_yaml/config/routes.yaml @@ -0,0 +1,4 @@ +report_show: + path: /reports/{id} + controller: App\Controller\ReportController::show + methods: [POST] diff --git a/tests/dynamic_fixtures/php_frameworks/symfony_yaml/src/Controller/ReportController.php b/tests/dynamic_fixtures/php_frameworks/symfony_yaml/src/Controller/ReportController.php new file mode 100644 index 00000000..d5376dfb --- /dev/null +++ b/tests/dynamic_fixtures/php_frameworks/symfony_yaml/src/Controller/ReportController.php @@ -0,0 +1,12 @@ +