refactor(dynamic): add cross-file route detection for frameworks, enhance test coverage in PHP and Ruby

This commit is contained in:
elipeter 2026-05-24 19:14:50 -05:00
parent 43ab4aa469
commit 0e8c900078
22 changed files with 1208 additions and 134 deletions

View file

@ -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<FrameworkBinding> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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<crate::dynamic::framework::MiddlewareShape> {
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<tree_sitter::Tree> {
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"<?php\nuse CodeIgniter\\Router\\RouteCollection;\n$routes->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"<?php\nnamespace App\\Controllers;\nclass UserController extends BaseController {\n public function show($num) { return $num; }\n}\n";
let tree = parse(src);
let mut project_files = ProjectFileIndex::new();
project_files.insert(
"app/Config/Routes.php",
b"<?php\nuse CodeIgniter\\Router\\RouteCollection;\n$routes->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"<?php\n$routes->get('users/(:num)', 'UserController::show');\n";

View file

@ -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<FrameworkBinding> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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<RouteShape> {
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<crate::dynamic::framework::MiddlewareShape> {
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<tree_sitter::Tree> {
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"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::get('/users/{id}', 'UserController@show');\nclass UserController {\n public function show($id) { return $id; }\n}\n";
@ -139,6 +221,37 @@ mod tests {
assert_eq!(binding.route.unwrap().method, HttpMethod::DELETE);
}
#[test]
fn resolves_project_route_file_for_controller_method() {
let src: &[u8] = b"<?php\nnamespace App\\Http\\Controllers;\nclass UserController {\n public function show($id) { return $id; }\n}\n";
let tree = parse(src);
let mut project_files = ProjectFileIndex::new();
project_files.insert(
"routes/web.php",
b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nuse App\\Http\\Controllers\\UserController;\nRoute::get('/users/{id}', [UserController::class, 'show'])->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"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::match(['POST', 'PATCH'], '/jobs/{id}', [JobController::class, 'run']);\nclass JobController {\n public function run($id) { return $id; }\n}\n";

View file

@ -5,14 +5,14 @@
//! `#[Route('/api')]` prefix is concatenated with the method-level
//! path so `#[Route('/api')] + #[Route('/x')]` produces `"/api/x"`.
//!
//! YAML routing (`config/routes.yaml`) is not handled in v1 — the
//! attribute path covers >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<FrameworkBinding> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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<RouteShape> {
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<String>,
controller: Option<String>,
method: Option<HttpMethod>,
}
fn parse_symfony_yaml_routes(
bytes: &[u8],
method_name: &str,
class_name: Option<&str>,
) -> Option<RouteShape> {
let text = std::str::from_utf8(bytes).ok()?;
let mut current: Option<SymfonyYamlRoute> = 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<SymfonyYamlRoute>,
method_name: &str,
class_name: Option<&str>,
) -> Option<RouteShape> {
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<String> {
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<HttpMethod> {
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"<?php\nuse Symfony\\Component\\Routing\\Annotation\\Route;\n#[Route('/api')]\nclass UserController {\n #[Route('/users/{id}')]\n public function show($id) { return $id; }\n}\n";
@ -159,6 +326,38 @@ mod tests {
assert_eq!(route.path, "/x");
}
#[test]
fn resolves_project_yaml_route_config() {
let src: &[u8] = b"<?php\nnamespace App\\Controller;\nuse Symfony\\Bundle\\FrameworkBundle\\Controller\\AbstractController;\nclass ReportController extends AbstractController {\n public function show($id) { return $id; }\n}\n";
let tree = parse(src);
let mut project_files = ProjectFileIndex::new();
project_files.insert(
"config/routes.yaml",
b"report_show:\n path: /reports/{id}\n controller: App\\Controller\\ReportController::show\n methods: [POST]\n".to_vec(),
);
let context = FrameworkDetectionContext {
ssa_summary: None,
project_files: &project_files,
};
let binding = PhpSymfonyAdapter
.detect_with_project_context(
&summary_at("show", "/tmp/app/src/Controller/ReportController.php"),
context,
tree.root_node(),
src,
)
.expect("binding from config/routes.yaml");
let route = binding.route.unwrap();
assert_eq!(route.path, "/reports/{id}");
assert_eq!(route.method, HttpMethod::POST);
let id = binding
.request_params
.iter()
.find(|p| p.name == "id")
.unwrap();
assert!(matches!(id.source, ParamSource::PathSegment(_)));
}
#[test]
fn populates_middleware_from_is_granted_attribute() {
let src: &[u8] = b"<?php\nuse Symfony\\Component\\Routing\\Annotation\\Route;\nclass C {\n #[Route('/x')]\n #[IsGranted('ROLE_USER')]\n public function show() { return 1; }\n}\n";

View file

@ -4,12 +4,15 @@
//! inherits from `Hanami::Action` (v1 idiom) or includes the
//! `Hanami::Action` module (v2 idiom) plus a `call` method that
//! receives the request. When the class declaration carries a
//! sibling `# nyx-route:` comment line the adapter pulls the path
//! template from it; otherwise the binding falls back to
//! `/{snake_case(class)}` so harness emitters still have a usable
//! [`super::super::RouteShape`].
//! sibling `# nyx-route:` comment line or a project `config/routes.rb`
//! entry the adapter pulls the path template from it; otherwise the
//! binding falls back to `/{snake_case(class)}` so harness emitters
//! still have a usable [`super::super::RouteShape`].
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;
@ -33,19 +36,23 @@ fn class_is_hanami_action(class: Node<'_>, 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: <METHOD> <path>` comment fixtures rely on, then
/// finally a `(GET, fallback_path)` default.
///
/// Cross-file routes resolution (`config/routes.rb` + `app/actions/<slug>/<verb>.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: <METHOD> <path>` 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
/// `<verb> "<path>", to: "<target>"` (or single-quoted variants) and
/// matches `<target>` 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 `<target>` against the class name, its `snake_case` form,
/// or the `app/actions/<slug>/<verb>.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<String> {
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::<Vec<_>>()
.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<String>, 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<String> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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";

View file

@ -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<String, Vec<u8>>,
}
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<String>, bytes: impl Into<Vec<u8>>) {
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>) -> 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<FrameworkBinding> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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<FrameworkBinding> {
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);
}
}

View file

@ -718,8 +718,8 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result<HarnessSource, Un
/// Phase 19 (Track M.1) — class-method harness for Node.js / TypeScript.
///
/// Imports the entry module, locates `class` on the exported surface,
/// instantiates via the default constructor, falls back to a single
/// mock-dependency ctor when the zero-arg path throws, and invokes
/// recursively instantiates same-module constructor dependencies up to
/// depth 3, falls back to known mock dependencies, and invokes
/// `instance[method](payload)`.
fn emit_class_method(
_spec: &HarnessSpec,
@ -771,23 +771,73 @@ if (typeof _Cls !== 'function') {{
process.exit(78);
}}
function _nyxBuildReceiver(Cls) {{
try {{
return new Cls();
}} catch (_e) {{
// Fall back to a single mock-dependency ctor. The brief allows
// up to depth-3 dependency stubbing; v1 keeps the chain depth
// at one and lets the verifier promote precision in a later
// phase.
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;
}}
function _nyxExportedClass(name) {{
if (!name) return null;
if (_entry && typeof _entry[name] === 'function') return _entry[name];
if (_entry && _entry.default && typeof _entry.default[name] === 'function') return _entry.default[name];
if (_entry && typeof _entry.default === 'function' && _entry.default.name === name) return _entry.default;
return null;
}}
const _instance = _nyxBuildReceiver(_Cls);
function _nyxConstructorParams(Cls) {{
let src = '';
try {{ src = Function.prototype.toString.call(Cls); }} catch (_e) {{ return []; }}
const match = src.match(/constructor\s*\(([^)]*)\)/m);
if (!match) return [];
return match[1]
.split(',')
.map((part) => 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 || '<anonymous>';
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);

View file

@ -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<PathBuf> {
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

View file

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

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -0,0 +1,4 @@
<?php
use CodeIgniter\Router\RouteCollection;
$routes->get('users/(:num)', 'UserController::show');

View file

@ -0,0 +1,10 @@
<?php
namespace App\Controllers;
class UserController extends BaseController
{
public function show($num)
{
return $num;
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
class UserController
{
public function show($id)
{
return $id;
}
}

View file

@ -0,0 +1,5 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\UserController;
Route::get('/users/{id}', [UserController::class, 'show'])->middleware('auth');

View file

@ -0,0 +1,4 @@
report_show:
path: /reports/{id}
controller: App\Controller\ReportController::show
methods: [POST]

View file

@ -0,0 +1,12 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class ReportController extends AbstractController
{
public function show($id)
{
return $id;
}
}

View file

@ -0,0 +1,11 @@
require "hanami/action"
module Books
class Show
include Hanami::Action
def call(req)
req.params[:id]
end
end
end

View file

@ -0,0 +1,3 @@
Hanami.app.routes do
get "/books/:id", to: "books.show"
end

View file

@ -13,7 +13,10 @@
mod common;
use nyx_scanner::dynamic::framework::{HttpMethod, ParamSource, detect_binding};
use nyx_scanner::dynamic::framework::{
FrameworkDetectionContext, HttpMethod, ParamSource, ProjectFileIndex, detect_binding,
detect_binding_with_project_context,
};
use nyx_scanner::evidence::EntryKind;
use nyx_scanner::summary::FuncSummary;
use nyx_scanner::symbol::Lang;
@ -115,6 +118,30 @@ fn symfony_benign_fixture_binds_same_route_shape() {
assert_eq!(route.path, "/run");
}
#[test]
fn symfony_yaml_fixture_binds_cross_file_route() {
let path =
"tests/dynamic_fixtures/php_frameworks/symfony_yaml/src/Controller/ReportController.php";
let routes = "tests/dynamic_fixtures/php_frameworks/symfony_yaml/config/routes.yaml";
let bytes = std::fs::read(path).expect("symfony yaml controller fixture exists");
let route_bytes = std::fs::read(routes).expect("symfony yaml routes fixture exists");
let tree = parse_php(&bytes);
let summary = summary_for("show", path);
let mut project_files = ProjectFileIndex::new();
project_files.insert("config/routes.yaml", route_bytes);
let context = FrameworkDetectionContext {
ssa_summary: None,
project_files: &project_files,
};
let binding =
detect_binding_with_project_context(&summary, context, tree.root_node(), &bytes, Lang::Php)
.expect("symfony adapter must bind through config/routes.yaml");
assert_eq!(binding.adapter, "php-symfony");
let route = binding.route.as_ref().expect("route");
assert_eq!(route.path, "/reports/{id}");
assert_eq!(route.method, HttpMethod::POST);
}
#[test]
fn codeigniter_vuln_fixture_binds_route() {
let path = "tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php";
@ -143,6 +170,57 @@ fn codeigniter_benign_fixture_binds_same_route_shape() {
assert_eq!(route.path, "run");
}
#[test]
fn laravel_routes_fixture_binds_cross_file_route() {
let path = "tests/dynamic_fixtures/php_frameworks/laravel_routes/app/Http/Controllers/UserController.php";
let routes = "tests/dynamic_fixtures/php_frameworks/laravel_routes/routes/web.php";
let bytes = std::fs::read(path).expect("laravel controller fixture exists");
let route_bytes = std::fs::read(routes).expect("laravel routes fixture exists");
let tree = parse_php(&bytes);
let summary = summary_for("show", path);
let mut project_files = ProjectFileIndex::new();
project_files.insert("routes/web.php", route_bytes);
let context = FrameworkDetectionContext {
ssa_summary: None,
project_files: &project_files,
};
let binding =
detect_binding_with_project_context(&summary, context, tree.root_node(), &bytes, Lang::Php)
.expect("laravel adapter must bind through routes/web.php");
assert_eq!(binding.adapter, "php-laravel");
let route = binding.route.as_ref().expect("route");
assert_eq!(route.path, "/users/{id}");
assert_eq!(route.method, HttpMethod::GET);
assert!(
binding.middleware.iter().any(|m| m.name == "auth"),
"expected auth middleware from route config, got {:?}",
binding.middleware
);
}
#[test]
fn codeigniter_config_fixture_binds_cross_file_route() {
let path = "tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Controllers/UserController.php";
let routes = "tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Config/Routes.php";
let bytes = std::fs::read(path).expect("codeigniter controller fixture exists");
let route_bytes = std::fs::read(routes).expect("codeigniter routes fixture exists");
let tree = parse_php(&bytes);
let summary = summary_for("show", path);
let mut project_files = ProjectFileIndex::new();
project_files.insert("app/Config/Routes.php", route_bytes);
let context = FrameworkDetectionContext {
ssa_summary: None,
project_files: &project_files,
};
let binding =
detect_binding_with_project_context(&summary, context, tree.root_node(), &bytes, Lang::Php)
.expect("codeigniter adapter must bind through app/Config/Routes.php");
assert_eq!(binding.adapter, "php-codeigniter");
let route = binding.route.as_ref().expect("route");
assert_eq!(route.path, "users/(:num)");
assert_eq!(route.method, HttpMethod::GET);
}
#[test]
fn laravel_adapter_ignores_helper_method() {
// `helper` is declared but not referenced in any `Route::*` call.

View file

@ -11,7 +11,10 @@
#![cfg(feature = "dynamic")]
use nyx_scanner::dynamic::framework::{HttpMethod, ParamSource, detect_binding};
use nyx_scanner::dynamic::framework::{
FrameworkDetectionContext, HttpMethod, ParamSource, ProjectFileIndex, detect_binding,
detect_binding_with_project_context,
};
use nyx_scanner::evidence::EntryKind;
use nyx_scanner::summary::FuncSummary;
use nyx_scanner::symbol::Lang;
@ -147,6 +150,34 @@ fn hanami_benign_fixture_binds_same_route_shape() {
assert_eq!(route.path, "/run");
}
#[test]
fn hanami_config_routes_fixture_binds_cross_file_route() {
let path = "tests/dynamic_fixtures/ruby/hanami_config_routes/app/actions/books/show.rb";
let routes = "tests/dynamic_fixtures/ruby/hanami_config_routes/config/routes.rb";
let bytes = std::fs::read(path).expect("hanami action fixture exists");
let route_bytes = std::fs::read(routes).expect("hanami routes fixture exists");
let tree = parse_ruby(&bytes);
let summary = summary_for("call", path);
let mut project_files = ProjectFileIndex::new();
project_files.insert("config/routes.rb", route_bytes);
let context = FrameworkDetectionContext {
ssa_summary: None,
project_files: &project_files,
};
let binding = detect_binding_with_project_context(
&summary,
context,
tree.root_node(),
&bytes,
Lang::Ruby,
)
.expect("hanami adapter must bind through config/routes.rb");
assert_eq!(binding.adapter, "ruby-hanami");
let route = binding.route.as_ref().expect("route");
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(route.path, "/books/:id");
}
// ── Cross-adapter disambiguation ─────────────────────────────────────────────
#[test]