diff --git a/src/dynamic/framework/adapters/js_express.rs b/src/dynamic/framework/adapters/js_express.rs new file mode 100644 index 00000000..9d7c04e4 --- /dev/null +++ b/src/dynamic/framework/adapters/js_express.rs @@ -0,0 +1,166 @@ +//! Express [`super::super::FrameworkAdapter`] (Phase 13 — Track L.11). +//! +//! Recognises `app.get('/path', handler)`, `app.post('/path', handler)`, +//! `router.put('/path', handler)`, and the rest of the Express verb +//! dispatch surface (`get` / `head` / `post` / `put` / `patch` / +//! `delete` / `del` / `options` / `all`). Middleware-chained +//! registrations (`app.get('/x', authz, validate, handler)`) bind to +//! the last positional argument that references `summary.name`. +//! +//! Receiver aliases follow Express convention: bare `app`, +//! `application`, `router`, `api`, plus any name ending in `_router` / +//! `_app` / `Router` / `App`. Source-import sniffing requires one of +//! the well-known Express stanzas before the AST walk runs. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::js_routes::{ + bind_path_params, find_function_params, find_route_registration, function_formal_names, + source_imports_express, +}; + +pub struct JsExpressAdapter; + +const ADAPTER_NAME: &str = "js-express"; + +fn receiver_looks_like_express(name: &str) -> bool { + matches!( + name, + "app" | "application" | "router" | "api" | "expressApp" | "server" + ) || name.ends_with("_router") + || name.ends_with("_app") + || name.ends_with("Router") + || name.ends_with("App") +} + +impl FrameworkAdapter for JsExpressAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::JavaScript + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_express(file_bytes) { + return None; + } + let recv = receiver_looks_like_express; + let (method, path) = find_route_registration(ast, file_bytes, &summary.name, &recv)?; + let formals = find_function_params(ast, file_bytes, &summary.name) + .map(|p| function_formal_names(p, file_bytes)) + .unwrap_or_default(); + let request_params = bind_path_params(&formals, &path); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { method, path }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::{HttpMethod, ParamSource}; + + fn parse_js(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "javascript".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_app_get_with_named_handler() { + let src: &[u8] = b"const express = require('express');\n\ + const app = express();\n\ + function getUser(req, res) { res.send(req.params.id); }\n\ + app.get('/users/:id', getUser);\n"; + let tree = parse_js(src); + let binding = JsExpressAdapter + .detect(&summary("getUser"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.adapter, "js-express"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.as_ref().unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/users/:id"); + assert!(binding.request_params.iter().any(|p| p.name == "req" + && matches!(p.source, ParamSource::Implicit))); + assert!(binding.request_params.iter().any(|p| p.name == "res" + && matches!(p.source, ParamSource::Implicit))); + } + + #[test] + fn fires_on_post_via_router_alias() { + let src: &[u8] = b"const express = require('express');\n\ + const apiRouter = express.Router();\n\ + function saveItem(req, res) { res.json(req.body); }\n\ + apiRouter.post('/items', saveItem);\n"; + let tree = parse_js(src); + let binding = JsExpressAdapter + .detect(&summary("saveItem"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.as_ref().unwrap().method, HttpMethod::POST); + } + + #[test] + fn fires_on_middleware_chain() { + let src: &[u8] = b"const express = require('express');\n\ + const app = express();\n\ + function authz(req, res, next) { next(); }\n\ + function handler(req, res) { res.send('ok'); }\n\ + app.delete('/items/:id', authz, handler);\n"; + let tree = parse_js(src); + let binding = JsExpressAdapter + .detect(&summary("handler"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().method, HttpMethod::DELETE); + } + + #[test] + fn skips_when_express_not_imported() { + let src: &[u8] = b"const koa = require('koa');\n\ + const app = new koa();\n\ + function handler(ctx) { ctx.body = 'ok'; }\n\ + app.get('/x', handler);\n"; + let tree = parse_js(src); + assert!(JsExpressAdapter + .detect(&summary("handler"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_when_handler_name_does_not_match() { + let src: &[u8] = b"const express = require('express');\n\ + const app = express();\n\ + function other(req, res) { res.send('x'); }\n\ + app.get('/x', other);\n"; + let tree = parse_js(src); + assert!(JsExpressAdapter + .detect(&summary("missing"), tree.root_node(), src) + .is_none()); + } +} diff --git a/src/dynamic/framework/adapters/js_fastify.rs b/src/dynamic/framework/adapters/js_fastify.rs new file mode 100644 index 00000000..5f04d2bc --- /dev/null +++ b/src/dynamic/framework/adapters/js_fastify.rs @@ -0,0 +1,155 @@ +//! Fastify [`super::super::FrameworkAdapter`] (Phase 13 — Track L.11). +//! +//! Recognises three Fastify route-registration shapes: +//! - Verb dispatch: `fastify.get('/path', handler)`, +//! `fastify.post(...)`, `fastify.put(...)`, etc. +//! - Options-object: `fastify.route({ method: 'GET', url: '/path', +//! handler })`. +//! - Plugin route table: `fastify.register(async (instance, opts) => +//! { instance.get('/path', handler); })` — Phase 13 v1 fires the +//! inner verb dispatch directly (the outer plugin wrapper is +//! opaque to the AST walk). +//! +//! Receiver aliases cover the canonical Fastify names (`fastify`, +//! `server`, `instance`, `app`) plus any name ending in `_fastify` / +//! `_server` / `Server` / `Fastify`. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::js_routes::{ + bind_path_params, find_function_params, find_route_registration, function_formal_names, + source_imports_fastify, +}; + +pub struct JsFastifyAdapter; + +const ADAPTER_NAME: &str = "js-fastify"; + +fn receiver_looks_like_fastify(name: &str) -> bool { + matches!( + name, + "fastify" | "server" | "instance" | "app" | "application" + ) || name.ends_with("_fastify") + || name.ends_with("_server") + || name.ends_with("Server") + || name.ends_with("Fastify") +} + +impl FrameworkAdapter for JsFastifyAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::JavaScript + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_fastify(file_bytes) { + return None; + } + let recv = receiver_looks_like_fastify; + let (method, path) = find_route_registration(ast, file_bytes, &summary.name, &recv)?; + let formals = find_function_params(ast, file_bytes, &summary.name) + .map(|p| function_formal_names(p, file_bytes)) + .unwrap_or_default(); + let request_params = bind_path_params(&formals, &path); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { method, path }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::HttpMethod; + + fn parse_js(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "javascript".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_fastify_get() { + let src: &[u8] = b"const fastify = require('fastify')();\n\ + async function getUser(request, reply) { reply.send(request.params.id); }\n\ + fastify.get('/users/:id', getUser);\n"; + let tree = parse_js(src); + let binding = JsFastifyAdapter + .detect(&summary("getUser"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.adapter, "js-fastify"); + let route = binding.route.as_ref().unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/users/:id"); + } + + #[test] + fn fires_on_options_object_route() { + let src: &[u8] = b"const fastify = require('fastify')();\n\ + async function handler(request, reply) { reply.send('ok'); }\n\ + fastify.route({ method: 'POST', url: '/items', handler: handler });\n"; + let tree = parse_js(src); + let binding = JsFastifyAdapter + .detect(&summary("handler"), tree.root_node(), src) + .expect("binding"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::POST); + assert_eq!(route.path, "/items"); + } + + #[test] + fn fires_on_plugin_inner_verb_dispatch() { + // Phase 13 v1: the inner `instance.get(...)` registration is + // recognised even though the surrounding `fastify.register` + // plugin wrapper is opaque to the AST walk. Fastify's + // `instance` alias matches `receiver_looks_like_fastify`. + let src: &[u8] = b"const fastify = require('fastify')();\n\ + async function handler(request, reply) { reply.send('ok'); }\n\ + fastify.register(async (instance, opts) => {\n\ + instance.get('/inner', handler);\n\ + });\n"; + let tree = parse_js(src); + let binding = JsFastifyAdapter + .detect(&summary("handler"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().path, "/inner"); + } + + #[test] + fn skips_when_fastify_not_imported() { + let src: &[u8] = b"const express = require('express');\n\ + const app = express();\n\ + function h(req, res) {}\n\ + app.get('/x', h);\n"; + let tree = parse_js(src); + assert!(JsFastifyAdapter + .detect(&summary("h"), tree.root_node(), src) + .is_none()); + } +} diff --git a/src/dynamic/framework/adapters/js_koa.rs b/src/dynamic/framework/adapters/js_koa.rs new file mode 100644 index 00000000..3a6d2a0e --- /dev/null +++ b/src/dynamic/framework/adapters/js_koa.rs @@ -0,0 +1,212 @@ +//! Koa [`super::super::FrameworkAdapter`] (Phase 13 — Track L.11). +//! +//! Recognises `@koa/router` / `koa-router` route registrations +//! (`router.get('/path', handler)` etc.) plus bare `app.use(handler)` +//! middleware chains. The Koa adapter accepts the `router` / `koa-router` +//! verb dispatch surface (`get` / `post` / `put` / `patch` / `delete` / +//! `head` / `options` / `all`) and also matches the legacy `app.use` +//! middleware shape which has no path template (route is recorded as +//! `"/"`). + +use crate::dynamic::framework::{ + FrameworkAdapter, FrameworkBinding, HttpMethod, MiddlewareShape, RouteShape, +}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::js_routes::{ + bind_path_params, find_function_params, find_route_registration, function_formal_names, + last_segment, source_imports_koa, view_arg_references, +}; + +pub struct JsKoaAdapter; + +const ADAPTER_NAME: &str = "js-koa"; + +fn receiver_looks_like_koa(name: &str) -> bool { + matches!( + name, + "router" | "app" | "application" | "koaApp" | "koaRouter" | "api" + ) || name.ends_with("Router") + || name.ends_with("App") + || name.ends_with("_router") + || name.ends_with("_app") +} + +/// Walk `root` looking for `app.use(handler)` middleware registrations +/// that reference `target`. Returns the matched call node so callers +/// can stamp a middleware-shape binding when the verb-based dispatch +/// fails to fire. +fn find_use_middleware<'a>( + root: Node<'a>, + bytes: &[u8], + target: &str, +) -> Option> { + let mut hit: Option> = None; + walk_for_use(root, bytes, target, &mut hit); + hit +} + +fn walk_for_use<'a>( + node: Node<'a>, + bytes: &[u8], + target: &str, + out: &mut Option>, +) { + if out.is_some() { + return; + } + if node.kind() == "call_expression" + && let Some(callee) = node.child_by_field_name("function") + && callee.kind() == "member_expression" + && let Some(prop) = callee.child_by_field_name("property") + && let Some(prop_text) = prop.utf8_text(bytes).ok() + && prop_text == "use" + && let Some(object) = callee.child_by_field_name("object") + && let Some(obj_text) = object.utf8_text(bytes).ok() + && receiver_looks_like_koa(last_segment(obj_text)) + && let Some(args) = node.child_by_field_name("arguments") + { + let mut cur = args.walk(); + for c in args.named_children(&mut cur) { + if view_arg_references(c, bytes, target) { + *out = Some(node); + return; + } + } + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + walk_for_use(child, bytes, target, out); + } +} + +impl FrameworkAdapter for JsKoaAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::JavaScript + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_koa(file_bytes) { + return None; + } + let recv = receiver_looks_like_koa; + let formals_for = |path: &str| { + let formals = find_function_params(ast, file_bytes, &summary.name) + .map(|p| function_formal_names(p, file_bytes)) + .unwrap_or_default(); + bind_path_params(&formals, path) + }; + if let Some((method, path)) = + find_route_registration(ast, file_bytes, &summary.name, &recv) + { + let request_params = formals_for(&path); + return Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { method, path }), + request_params, + response_writer: None, + middleware: Vec::new(), + }); + } + // Fall back to `app.use(handler)` middleware registration. No + // verb / path information — record the binding so the harness + // still drives the middleware via a synthetic ctx. + if find_use_middleware(ast, file_bytes, &summary.name).is_some() { + let request_params = formals_for("/"); + return Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: HttpMethod::GET, + path: "/".to_owned(), + }), + request_params, + response_writer: None, + middleware: vec![MiddlewareShape { + name: "koa.use".to_owned(), + }], + }); + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::ParamSource; + + fn parse_js(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "javascript".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_router_get() { + let src: &[u8] = b"const Koa = require('koa');\n\ + const Router = require('@koa/router');\n\ + const app = new Koa();\n\ + const router = new Router();\n\ + async function getUser(ctx) { ctx.body = ctx.params.id; }\n\ + router.get('/users/:id', getUser);\n"; + let tree = parse_js(src); + let binding = JsKoaAdapter + .detect(&summary("getUser"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.adapter, "js-koa"); + let route = binding.route.as_ref().unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/users/:id"); + assert!(binding.request_params.iter().any(|p| p.name == "ctx" + && matches!(p.source, ParamSource::Implicit))); + } + + #[test] + fn fires_on_app_use_middleware() { + let src: &[u8] = b"const Koa = require('koa');\n\ + const app = new Koa();\n\ + async function logger(ctx, next) { await next(); }\n\ + app.use(logger);\n"; + let tree = parse_js(src); + let binding = JsKoaAdapter + .detect(&summary("logger"), tree.root_node(), src) + .expect("middleware binding"); + assert_eq!(binding.middleware.len(), 1); + assert_eq!(binding.middleware[0].name, "koa.use"); + } + + #[test] + fn skips_when_koa_not_imported() { + let src: &[u8] = b"const express = require('express');\n\ + const router = express.Router();\n\ + function h(req, res) {}\n\ + router.get('/x', h);\n"; + let tree = parse_js(src); + assert!(JsKoaAdapter + .detect(&summary("h"), tree.root_node(), src) + .is_none()); + } +} diff --git a/src/dynamic/framework/adapters/js_nest.rs b/src/dynamic/framework/adapters/js_nest.rs new file mode 100644 index 00000000..bc5ced6f --- /dev/null +++ b/src/dynamic/framework/adapters/js_nest.rs @@ -0,0 +1,569 @@ +//! NestJS [`super::super::FrameworkAdapter`] (Phase 13 — Track L.11). +//! +//! Recognises Nest's controller-class decorator surface: +//! - `@Controller('users')` on the class establishes the route +//! prefix. +//! - `@Get(':id')` / `@Post()` / `@Put('/x')` / `@Patch()` / +//! `@Delete()` / `@Head()` / `@Options()` / `@All()` on the +//! method establishes the verb + sub-path; the full route is the +//! concatenation `prefix + path`. +//! - Parameter decorators (`@Param('id')`, `@Query('q')`, +//! `@Body()`, `@Headers()`, `@Req()`, `@Res()`) bind individual +//! formals to request slots. +//! +//! NestJS is TypeScript-first. The adapter is registered under both +//! [`Lang::TypeScript`] and [`Lang::JavaScript`] so Babel-transpiled +//! Nest projects (still common in the wild) are not silently +//! skipped — JS Nest projects emit the same decorator syntax via +//! `experimentalDecorators` / `legacyDecorators`. The lang-aware +//! tree-sitter parser is picked from `summary.lang`. + +use crate::dynamic::framework::{ + FrameworkAdapter, FrameworkBinding, HttpMethod, ParamBinding, ParamSource, RouteShape, +}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::js_routes::{ + bind_path_params, extract_path_placeholders, function_formal_names, http_verb_from_method, + source_imports_nest, strip_quotes, +}; + +pub struct JsNestAdapter; +pub struct TsNestAdapter; + +const JS_ADAPTER_NAME: &str = "js-nest"; +const TS_ADAPTER_NAME: &str = "ts-nest"; + +impl FrameworkAdapter for JsNestAdapter { + fn name(&self) -> &'static str { + JS_ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::JavaScript + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_nest(summary, ast, file_bytes, JS_ADAPTER_NAME) + } +} + +impl FrameworkAdapter for TsNestAdapter { + fn name(&self) -> &'static str { + TS_ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::TypeScript + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + detect_nest(summary, ast, file_bytes, TS_ADAPTER_NAME) + } +} + +fn detect_nest( + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + adapter_name: &'static str, +) -> Option { + if !source_imports_nest(file_bytes) { + return None; + } + let (class_node, method_node) = + find_class_method(ast, file_bytes, &summary.name)?; + let prefix = class_controller_prefix(class_node, file_bytes)?; + let (method, sub_path) = method_verb_and_path(method_node, file_bytes)?; + let full_path = join_paths(&prefix, &sub_path); + let formals = method_node + .child_by_field_name("parameters") + .map(|p| function_formal_names(p, file_bytes)) + .unwrap_or_default(); + let mut request_params = bind_path_params(&formals, &full_path); + refine_with_param_decorators(method_node, file_bytes, &mut request_params, &full_path); + Some(FrameworkBinding { + adapter: adapter_name.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method, + path: full_path, + }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) +} + +/// Find `(class_declaration, method_definition)` where the method's +/// `name` field equals `target` and the enclosing class is decorated +/// with `@Controller(...)`. Returns the first match in document +/// order. +fn find_class_method<'a>( + root: Node<'a>, + bytes: &[u8], + target: &str, +) -> Option<(Node<'a>, Node<'a>)> { + let mut hit: Option<(Node<'a>, Node<'a>)> = None; + walk_for_class_method(root, bytes, target, &mut hit); + hit +} + +fn walk_for_class_method<'a>( + node: Node<'a>, + bytes: &[u8], + target: &str, + out: &mut Option<(Node<'a>, Node<'a>)>, +) { + if out.is_some() { + return; + } + if node.kind() == "class_declaration" + && class_has_controller(node, bytes) + && let Some(body) = node.child_by_field_name("body") + { + let mut cur = body.walk(); + for child in body.named_children(&mut cur) { + if child.kind() == "method_definition" + && let Some(name) = child + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()) + && name == target + { + *out = Some((node, child)); + return; + } + } + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + walk_for_class_method(child, bytes, target, out); + } +} + +/// True when `class_node` is preceded by (or contains, depending on +/// grammar version) an `@Controller(...)` decorator. The walk +/// inspects both the class's own `decorator` field children +/// (tree-sitter-typescript) and its preceding siblings in the parent +/// (tree-sitter-javascript with legacy decorator transform), so the +/// adapter fires regardless of the grammar's wrapping. +fn class_has_controller(class_node: Node<'_>, bytes: &[u8]) -> bool { + if decorator_named(class_node, bytes, "Controller", &mut |_| {}) { + return true; + } + let mut prev = class_node.prev_named_sibling(); + while let Some(sib) = prev { + if sib.kind() == "decorator" { + if decorator_text_is(sib, bytes, "Controller") { + return true; + } + prev = sib.prev_named_sibling(); + continue; + } + break; + } + false +} + +/// Extract the controller-prefix string from a class's +/// `@Controller()` decorator. Returns `Some("")` when the +/// decorator carries no argument (`@Controller()` is valid Nest — it +/// mounts the controller at root). +fn class_controller_prefix(class_node: Node<'_>, bytes: &[u8]) -> Option { + let mut found: Option = None; + let mut catcher = |text: Option<&str>| { + if let Some(t) = text { + found = Some(t.to_owned()); + } else if found.is_none() { + found = Some(String::new()); + } + }; + if decorator_named(class_node, bytes, "Controller", &mut catcher) { + return found; + } + let mut prev = class_node.prev_named_sibling(); + while let Some(sib) = prev { + if sib.kind() == "decorator" { + if decorator_text_is(sib, bytes, "Controller") { + let arg = decorator_first_string_arg(sib, bytes); + return Some(arg.unwrap_or_default()); + } + prev = sib.prev_named_sibling(); + continue; + } + break; + } + None +} + +/// Return `Some((verb, sub_path))` when `method_node` is decorated +/// with one of the Nest verb decorators (`@Get`, `@Post`, ...). The +/// `sub_path` is `""` when the decorator carries no argument +/// (`@Get()` mounts at the controller prefix root). +fn method_verb_and_path( + method_node: Node<'_>, + bytes: &[u8], +) -> Option<(HttpMethod, String)> { + const VERBS: &[&str] = &[ + "Get", "Head", "Post", "Put", "Patch", "Delete", "Options", "All", + ]; + for &verb in VERBS { + if decorator_named(method_node, bytes, verb, &mut |_| {}) + && let Some(method) = http_verb_from_method(verb) + { + let path = method_decorator_path(method_node, bytes, verb); + return Some((method, path)); + } + } + // Phase 13 v1: also accept preceding-sibling decorators for + // grammar variants that hoist method decorators out of the + // method_definition node. + let mut prev = method_node.prev_named_sibling(); + while let Some(sib) = prev { + if sib.kind() == "decorator" { + for &verb in VERBS { + if decorator_text_is(sib, bytes, verb) + && let Some(method) = http_verb_from_method(verb) + { + let path = decorator_first_string_arg(sib, bytes).unwrap_or_default(); + return Some((method, path)); + } + } + prev = sib.prev_named_sibling(); + continue; + } + break; + } + None +} + +fn method_decorator_path(method_node: Node<'_>, bytes: &[u8], verb: &str) -> String { + let mut cur = method_node.walk(); + for d in method_node.children_by_field_name("decorator", &mut cur) { + if decorator_text_is(d, bytes, verb) { + return decorator_first_string_arg(d, bytes).unwrap_or_default(); + } + } + String::new() +} + +/// Walk `node`'s `decorator` field children invoking `callback` for +/// each decorator named `name`. Returns `true` when at least one +/// matching decorator was found. `callback` receives the first +/// string argument (or `None` when the decorator carries no +/// arguments). +fn decorator_named( + node: Node<'_>, + bytes: &[u8], + name: &str, + callback: &mut dyn FnMut(Option<&str>), +) -> bool { + let mut found = false; + let mut cur = node.walk(); + for d in node.children_by_field_name("decorator", &mut cur) { + if decorator_text_is(d, bytes, name) { + found = true; + let arg = decorator_first_string_arg(d, bytes); + callback(arg.as_deref()); + } + } + found +} + +fn decorator_text_is(decorator: Node<'_>, bytes: &[u8], name: &str) -> bool { + let mut cur = decorator.walk(); + for c in decorator.children(&mut cur) { + if c.kind() == "@" { + continue; + } + let text = c.utf8_text(bytes).unwrap_or(""); + // Strip optional `(args)` so `@Get(':id')` matches the name `Get`. + let head = text.split('(').next().unwrap_or(text).trim(); + if head == name { + return true; + } + } + false +} + +fn decorator_first_string_arg(decorator: Node<'_>, bytes: &[u8]) -> Option { + let mut cur = decorator.walk(); + for c in decorator.children(&mut cur) { + if c.kind() == "call_expression" + && let Some(args) = c.child_by_field_name("arguments") + { + let mut ac = args.walk(); + for a in args.named_children(&mut ac) { + if a.kind() == "string" || a.kind() == "template_string" { + let raw = a.utf8_text(bytes).ok()?; + return Some(strip_quotes(raw).to_owned()); + } + } + } + } + None +} + +/// Refine the per-formal binding shape using Nest's parameter +/// decorators (`@Param('id')`, `@Query('q')`, `@Body()`, `@Headers()`, +/// `@Req()` / `@Res()`). A `@Body()` formal becomes +/// [`ParamSource::JsonBody`]; a `@Param('x')` formal becomes +/// [`ParamSource::PathSegment`]; `@Query('q')` keeps +/// [`ParamSource::QueryParam`]; `@Req()` / `@Res()` becomes +/// [`ParamSource::Implicit`]. +fn refine_with_param_decorators( + method_node: Node<'_>, + bytes: &[u8], + bindings: &mut [ParamBinding], + full_path: &str, +) { + let Some(params) = method_node.child_by_field_name("parameters") else { + return; + }; + let mut cur = params.walk(); + let placeholders = extract_path_placeholders(full_path); + let formal_param_nodes: Vec> = params.named_children(&mut cur).collect(); + for (idx, formal) in formal_param_nodes.iter().enumerate() { + if let Some(refinement) = classify_param_decorator(*formal, bytes, &placeholders) + && let Some(slot) = bindings.get_mut(idx) + { + slot.source = refinement; + } + } +} + +fn classify_param_decorator( + formal: Node<'_>, + bytes: &[u8], + placeholders: &[String], +) -> Option { + let mut cur = formal.walk(); + for d in formal.children_by_field_name("decorator", &mut cur) { + if let Some(refinement) = decorator_to_param_source(d, bytes, placeholders) { + return Some(refinement); + } + } + // Some grammar variants attach the decorator as a preceding + // sibling inside the parameter list. + let mut prev = formal.prev_named_sibling(); + while let Some(sib) = prev { + if sib.kind() == "decorator" { + if let Some(r) = decorator_to_param_source(sib, bytes, placeholders) { + return Some(r); + } + prev = sib.prev_named_sibling(); + continue; + } + break; + } + None +} + +fn decorator_to_param_source( + decorator: Node<'_>, + bytes: &[u8], + placeholders: &[String], +) -> Option { + let arg = decorator_first_string_arg(decorator, bytes); + if decorator_text_is(decorator, bytes, "Body") { + return Some(ParamSource::JsonBody); + } + if decorator_text_is(decorator, bytes, "Param") { + let name = arg.unwrap_or_else(|| { + placeholders + .first() + .cloned() + .unwrap_or_else(|| "id".to_owned()) + }); + return Some(ParamSource::PathSegment(name)); + } + if decorator_text_is(decorator, bytes, "Query") { + let name = arg.unwrap_or_else(|| "q".to_owned()); + return Some(ParamSource::QueryParam(name)); + } + if decorator_text_is(decorator, bytes, "Headers") { + let name = arg.unwrap_or_else(|| "x-nyx".to_owned()); + return Some(ParamSource::Header(name)); + } + if decorator_text_is(decorator, bytes, "Req") + || decorator_text_is(decorator, bytes, "Res") + || decorator_text_is(decorator, bytes, "Request") + || decorator_text_is(decorator, bytes, "Response") + || decorator_text_is(decorator, bytes, "Next") + { + return Some(ParamSource::Implicit); + } + None +} + +/// Join a controller prefix and method path segment per Nest's own +/// path normalisation: collapse any double-slash run to a single +/// slash, ensure the result starts with `/`, and trim a trailing +/// slash unless the path is `/` itself. +fn join_paths(prefix: &str, sub_path: &str) -> String { + let mut combined = String::with_capacity(prefix.len() + sub_path.len() + 2); + if !prefix.starts_with('/') { + combined.push('/'); + } + combined.push_str(prefix); + if !prefix.ends_with('/') && !sub_path.is_empty() && !sub_path.starts_with('/') { + combined.push('/'); + } + combined.push_str(sub_path); + let collapsed = collapse_slashes(&combined); + if collapsed.is_empty() { + return "/".to_owned(); + } + collapsed +} + +fn collapse_slashes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut last_was_slash = false; + for c in s.chars() { + if c == '/' { + if !last_was_slash { + out.push('/'); + } + last_was_slash = true; + } else { + out.push(c); + last_was_slash = false; + } + } + if out.len() > 1 { + while out.ends_with('/') { + out.pop(); + } + } + if out.is_empty() { + return "/".to_owned(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_ts(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = + tree_sitter::Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str, lang: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: lang.into(), + ..Default::default() + } + } + + #[test] + fn collapse_slashes_normalises_join() { + assert_eq!(join_paths("users", "id"), "/users/id"); + assert_eq!(join_paths("/users/", "/:id"), "/users/:id"); + assert_eq!(join_paths("", ""), "/"); + assert_eq!(join_paths("/", "/"), "/"); + } + + #[test] + fn fires_on_controller_get_decorator() { + let src: &[u8] = b"import { Controller, Get, Param } from '@nestjs/common';\n\ + @Controller('users')\n\ + export class UsersController {\n\ + @Get(':id')\n\ + getUser(@Param('id') id: string) { return id; }\n\ + }\n"; + let tree = parse_ts(src); + let binding = TsNestAdapter + .detect(&summary("getUser", "typescript"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.adapter, "ts-nest"); + let route = binding.route.as_ref().unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/users/:id"); + let id_binding = binding + .request_params + .iter() + .find(|p| p.name == "id") + .unwrap(); + assert!(matches!(id_binding.source, ParamSource::PathSegment(_))); + } + + #[test] + fn fires_on_post_with_body_decorator() { + let src: &[u8] = b"import { Controller, Post, Body } from '@nestjs/common';\n\ + @Controller('items')\n\ + export class ItemsController {\n\ + @Post()\n\ + create(@Body() payload: any) { return payload; }\n\ + }\n"; + let tree = parse_ts(src); + let binding = TsNestAdapter + .detect(&summary("create", "typescript"), tree.root_node(), src) + .expect("binding"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::POST); + assert_eq!(route.path, "/items"); + let body_binding = binding + .request_params + .iter() + .find(|p| p.name == "payload") + .unwrap(); + assert!(matches!(body_binding.source, ParamSource::JsonBody)); + } + + #[test] + fn fires_on_query_decorator() { + let src: &[u8] = b"import { Controller, Get, Query } from '@nestjs/common';\n\ + @Controller()\n\ + export class SearchController {\n\ + @Get('search')\n\ + search(@Query('q') q: string) { return q; }\n\ + }\n"; + let tree = parse_ts(src); + let binding = TsNestAdapter + .detect(&summary("search", "typescript"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().path, "/search"); + let q_binding = binding + .request_params + .iter() + .find(|p| p.name == "q") + .unwrap(); + match &q_binding.source { + ParamSource::QueryParam(name) => assert_eq!(name, "q"), + other => panic!("expected QueryParam, got {other:?}"), + } + } + + #[test] + fn skips_when_not_a_nest_controller() { + let src: &[u8] = b"import { Injectable } from '@nestjs/common';\n\ + @Injectable()\n\ + export class HelperService {\n\ + compute(x: number) { return x + 1; }\n\ + }\n"; + let tree = parse_ts(src); + assert!(TsNestAdapter + .detect(&summary("compute", "typescript"), tree.root_node(), src) + .is_none()); + } +} diff --git a/src/dynamic/framework/adapters/js_routes.rs b/src/dynamic/framework/adapters/js_routes.rs new file mode 100644 index 00000000..b1adadee --- /dev/null +++ b/src/dynamic/framework/adapters/js_routes.rs @@ -0,0 +1,666 @@ +//! Shared JS/TS route adapter helpers (Phase 13 — Track L.11). +//! +//! The Express / Koa / NestJS / Fastify adapters all share a handful of +//! tree-sitter helpers: source-import sniffers, formal-name extractors, +//! callee-receiver normalisation, path-placeholder extraction, and a +//! per-formal binder that promotes `req` / `res` / `ctx` / `next` / +//! `reply` to [`ParamSource::Implicit`] and the rest to either +//! [`ParamSource::PathSegment`] or [`ParamSource::QueryParam`] depending +//! on whether a placeholder of the same name appears in the path +//! template. + +use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource}; +use tree_sitter::Node; + +/// True when `bytes` carries any of the well-known Express import +/// stanzas (CommonJS or ESM). Includes router-level imports +/// (`express.Router()`) so adapters can fire on files that only pull +/// in the router builder. +pub fn source_imports_express(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"require('express')", + b"require(\"express\")", + b"from 'express'", + b"from \"express\"", + b"express.Router(", + b"express.Router()", + ], + ) +} + +/// True when `bytes` carries any of the well-known Koa import stanzas. +/// Covers Koa itself, `@koa/router`, and `koa-router`. +pub fn source_imports_koa(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"require('koa')", + b"require(\"koa\")", + b"from 'koa'", + b"from \"koa\"", + b"require('@koa/router')", + b"require(\"@koa/router\")", + b"from '@koa/router'", + b"from \"@koa/router\"", + b"require('koa-router')", + b"require(\"koa-router\")", + b"from 'koa-router'", + b"from \"koa-router\"", + ], + ) +} + +/// True when `bytes` carries any of the well-known Fastify import +/// stanzas. +pub fn source_imports_fastify(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"require('fastify')", + b"require(\"fastify\")", + b"from 'fastify'", + b"from \"fastify\"", + b"fastify(", + ], + ) +} + +/// True when `bytes` carries any of the well-known NestJS import +/// stanzas. NestJS is TypeScript-first so the markers include both the +/// decorator-import packages and the platform / factory entry points. +pub fn source_imports_nest(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"@nestjs/common", + b"@nestjs/core", + b"@nestjs/platform-express", + b"@nestjs/platform-fastify", + b"NestFactory", + b"@Controller", + ], + ) +} + +fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool { + needles + .iter() + .any(|n| haystack.windows(n.len()).any(|w| w == *n)) +} + +/// Extract the last segment of a member expression chain so +/// `app.get` / `router.get` / `fastify.get` all reduce to `"get"`. +/// Used by the per-framework adapters to classify the HTTP verb +/// regardless of the receiver alias. +pub fn last_segment(callee: &str) -> &str { + callee.rsplit_once('.').map(|(_, s)| s).unwrap_or(callee) +} + +/// Map a route-method name (`get` / `post` / `put` / `patch` / +/// `delete` / `options` / `head` / `all`) to an [`HttpMethod`]. +/// Returns `None` for callees that do not look like an HTTP-verb +/// dispatch (so non-route `app.use(handler)` does not fire). +pub fn http_verb_from_method(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "get" => Some(HttpMethod::GET), + "head" => Some(HttpMethod::HEAD), + "post" => Some(HttpMethod::POST), + "put" => Some(HttpMethod::PUT), + "patch" => Some(HttpMethod::PATCH), + "delete" | "del" => Some(HttpMethod::DELETE), + "options" => Some(HttpMethod::OPTIONS), + // `app.all` registers the handler against every verb — pick + // GET as the canonical replay. + "all" => Some(HttpMethod::GET), + _ => None, + } +} + +/// Strip the surrounding quotes (`'`, `"`, or backticks) from a JS +/// string literal node's source text. Returns the inner slice when +/// the literal is single-line and unquoted bytes only — multi-line +/// template literals fall back to the trimmed input. +pub fn strip_quotes(raw: &str) -> &str { + let trimmed = raw.trim(); + if (trimmed.starts_with('\'') && trimmed.ends_with('\'')) + || (trimmed.starts_with('"') && trimmed.ends_with('"')) + || (trimmed.starts_with('`') && trimmed.ends_with('`')) + { + let bytes = trimmed.as_bytes(); + if bytes.len() >= 2 { + return &trimmed[1..trimmed.len() - 1]; + } + } + trimmed +} + +/// Find a top-level function declaration / function expression / +/// arrow function whose binding name equals `target`. Returns the +/// `formal_parameters` (or `formal_parameter` for shorthand arrows) +/// node so callers can enumerate parameter names. +pub fn find_function_params<'a>( + root: Node<'a>, + bytes: &[u8], + target: &str, +) -> Option> { + let mut hit: Option> = None; + walk_for_params(root, bytes, target, &mut hit); + hit +} + +fn walk_for_params<'a>( + node: Node<'a>, + bytes: &[u8], + target: &str, + out: &mut Option>, +) { + if out.is_some() { + return; + } + match node.kind() { + "function_declaration" | "generator_function_declaration" => { + if let Some(name) = node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()) + && name == target + && let Some(params) = node.child_by_field_name("parameters") + { + *out = Some(params); + return; + } + } + "method_definition" => { + if let Some(name) = node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()) + && name == target + && let Some(params) = node.child_by_field_name("parameters") + { + *out = Some(params); + return; + } + } + "variable_declarator" | "assignment_expression" => { + // `const name = function() {}`, `const name = (a,b) => ...`, + // `name = function() {}`. + let name_field = if node.kind() == "variable_declarator" { + "name" + } else { + "left" + }; + if let Some(name_node) = node.child_by_field_name(name_field) + && let Some(name) = name_node.utf8_text(bytes).ok() + && name == target + && let Some(value) = node.child_by_field_name("value").or_else(|| { + if node.kind() == "assignment_expression" { + node.child_by_field_name("right") + } else { + None + } + }) + { + match value.kind() { + "function_expression" + | "function" + | "arrow_function" + | "generator_function" => { + if let Some(params) = value.child_by_field_name("parameters") { + *out = Some(params); + return; + } + } + _ => {} + } + } + } + _ => {} + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + walk_for_params(child, bytes, target, out); + } +} + +/// Enumerate identifier names from a `formal_parameters` node. Skips +/// the rest-element marker (`...`) and any destructuring wrappers so +/// the returned vector lines up with positional ordering of declared +/// parameters. +pub fn function_formal_names(params: Node<'_>, bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut cur = params.walk(); + for child in params.named_children(&mut cur) { + if let Some(name) = parameter_name(child, bytes) { + out.push(name); + } + } + out +} + +fn parameter_name(node: Node<'_>, bytes: &[u8]) -> Option { + match node.kind() { + "identifier" | "shorthand_property_identifier_pattern" => { + node.utf8_text(bytes).ok().map(str::to_owned) + } + "assignment_pattern" | "required_parameter" | "optional_parameter" => { + // `x = 1` / TypeScript `x: T` / `x?: T` + if let Some(left) = node.child_by_field_name("left") { + return parameter_name(left, bytes); + } + if let Some(pattern) = node.child_by_field_name("pattern") { + return parameter_name(pattern, bytes); + } + let mut cur = node.walk(); + for c in node.named_children(&mut cur) { + if c.kind() == "identifier" { + return c.utf8_text(bytes).ok().map(str::to_owned); + } + if let Some(n) = parameter_name(c, bytes) { + return Some(n); + } + } + None + } + "rest_pattern" | "object_pattern" | "array_pattern" => { + let mut cur = node.walk(); + for c in node.named_children(&mut cur) { + if let Some(n) = parameter_name(c, bytes) { + return Some(n); + } + } + None + } + _ => None, + } +} + +/// Bind formals to request slots given a route path template. +/// +/// Accepts three placeholder syntaxes simultaneously: Express / +/// Fastify `:id`, FastAPI / Starlette `{id}`, and Hapi-style +/// `{id?}`. A formal whose name matches a placeholder becomes a +/// [`ParamSource::PathSegment`]; the well-known framework context +/// formals (`req` / `request` / `res` / `response` / `reply` / +/// `ctx` / `context` / `next`) become +/// [`ParamSource::Implicit`]; everything else falls back to +/// [`ParamSource::QueryParam`] so downstream harness emitters have +/// a deterministic slot to populate. +pub fn bind_path_params(formals: &[String], path: &str) -> Vec { + let placeholders = extract_path_placeholders(path); + formals + .iter() + .enumerate() + .map(|(idx, name)| { + let source = if is_implicit_formal(name) { + ParamSource::Implicit + } else if placeholders.iter().any(|p| p == name) { + ParamSource::PathSegment(name.clone()) + } else { + ParamSource::QueryParam(name.clone()) + }; + ParamBinding { + index: idx, + name: name.clone(), + source, + } + }) + .collect() +} + +fn is_implicit_formal(name: &str) -> bool { + matches!( + name, + "req" + | "request" + | "res" + | "response" + | "reply" + | "ctx" + | "context" + | "next" + | "done" + ) +} + +/// Extract placeholder names from a route path template. +/// +/// Supports three placeholder syntaxes: +/// - Express / Fastify / NestJS: `/users/:id` → `id`, +/// `/users/:id(\\d+)` → `id` (anything inside `()` is dropped). +/// - FastAPI / Starlette mirrors: `/users/{id}` → `id`. +/// - Hapi-style optional: `/users/{id?}` → `id`. +/// +/// Names are deduplicated while preserving first-occurrence order so a +/// single placeholder reused across the path does not double-bind a +/// formal. +pub fn extract_path_placeholders(path: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut push = |name: String| { + let trimmed = name.trim_end_matches(['?', '*']).to_owned(); + if !trimmed.is_empty() && !out.iter().any(|n| n == &trimmed) { + out.push(trimmed); + } + }; + let bytes = path.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b':' => { + let start = i + 1; + let mut j = start; + while j < bytes.len() + && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') + { + j += 1; + } + if j > start { + push(path[start..j].to_owned()); + } + // Skip a parenthesised regex constraint like `:id(\\d+)`. + if j < bytes.len() && bytes[j] == b'(' { + let mut depth = 1usize; + j += 1; + while j < bytes.len() && depth > 0 { + match bytes[j] { + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} + } + j += 1; + } + } + i = j; + continue; + } + b'{' => { + if let Some(end) = bytes[i + 1..].iter().position(|&b| b == b'}') { + let inner = &path[i + 1..i + 1 + end]; + let name = inner.split(':').next().unwrap_or(inner); + push(name.to_owned()); + i += end + 2; + continue; + } + } + _ => {} + } + i += 1; + } + out +} + +/// True when `view_arg` references `target` either directly +/// (`handler`) or as a member expression whose last segment is +/// `target` (`controller.handler` / `module.exports.handler`). +pub fn view_arg_references(view_arg: Node<'_>, bytes: &[u8], target: &str) -> bool { + match view_arg.kind() { + "identifier" => view_arg + .utf8_text(bytes) + .ok() + .map(|t| t == target) + .unwrap_or(false), + "member_expression" => view_arg + .utf8_text(bytes) + .ok() + .map(|t| last_segment(t) == target) + .unwrap_or(false), + _ => false, + } +} + +/// Walk `root` searching for a call expression `.(, ..., )` +/// or `.({ method, url, handler })` (Fastify-style +/// options-object). When the callee is one of the well-known HTTP +/// verbs, the receiver name is accepted by `receiver_accepts`, and one +/// of the positional arguments references `target`, returns the +/// `(method, path)` pair extracted from the first positional string +/// argument. +/// +/// The receiver check uses a closure so each per-framework adapter +/// can accept its own canonical aliases (`app` / `router` for Express, +/// `fastify` / `server` for Fastify, etc.) without re-walking the +/// AST. The handler position is permissive: any positional arg whose +/// identifier matches `target` (or whose last member-expression segment +/// matches) is accepted, so middleware-chained registrations +/// (`app.get('/x', authz, handler)`) bind correctly. +pub fn find_route_registration<'a>( + root: Node<'a>, + bytes: &[u8], + target: &str, + receiver_accepts: &dyn Fn(&str) -> bool, +) -> Option<(HttpMethod, String)> { + let mut hit: Option<(HttpMethod, String)> = None; + walk_for_registration(root, bytes, target, receiver_accepts, &mut hit); + hit +} + +fn walk_for_registration<'a>( + node: Node<'a>, + bytes: &[u8], + target: &str, + receiver_accepts: &dyn Fn(&str) -> bool, + out: &mut Option<(HttpMethod, String)>, +) { + if out.is_some() { + return; + } + if node.kind() == "call_expression" + && let Some(callee) = node.child_by_field_name("function") + && callee.kind() == "member_expression" + && let Some(object) = callee.child_by_field_name("object") + && let Some(property) = callee.child_by_field_name("property") + && let Some(object_text) = object.utf8_text(bytes).ok() + && let Some(prop_text) = property.utf8_text(bytes).ok() + { + if let Some(method) = http_verb_from_method(prop_text) + && receiver_accepts(last_segment(object_text)) + && let Some(args) = node.child_by_field_name("arguments") + { + if call_args_reference_target(args, bytes, target) { + if let Some(path) = first_string_arg(args, bytes) { + *out = Some((method, path)); + return; + } + } + } + // Fastify options-object: `fastify.route({ method, url, handler })`. + if prop_text == "route" + && receiver_accepts(last_segment(object_text)) + && let Some(args) = node.child_by_field_name("arguments") + && let Some((method, path)) = parse_options_route(args, bytes, target) + { + *out = Some((method, path)); + return; + } + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + walk_for_registration(child, bytes, target, receiver_accepts, out); + } +} + +/// True when any positional argument in `args` references `target` — +/// either as a bare identifier or as the last segment of a +/// `member_expression`. Skips object literals (Fastify's options-form +/// is matched separately by [`parse_options_route`]). +fn call_args_reference_target(args: Node<'_>, bytes: &[u8], target: &str) -> bool { + let mut cur = args.walk(); + for c in args.named_children(&mut cur) { + if view_arg_references(c, bytes, target) { + return true; + } + } + false +} + +/// Find the first positional string-literal argument in an +/// `arguments` node. Returns the literal's inner text with the +/// surrounding quotes stripped. +pub fn first_string_arg(args: Node<'_>, bytes: &[u8]) -> Option { + let mut cur = args.walk(); + for c in args.named_children(&mut cur) { + if c.kind() == "string" || c.kind() == "template_string" { + let raw = c.utf8_text(bytes).ok()?; + return Some(strip_quotes(raw).to_owned()); + } + } + None +} + +/// Parse a Fastify options-object call `fastify.route({ method, url, +/// handler })` returning the bound `(method, url)` when the +/// `handler:` property references `target`. +fn parse_options_route( + args: Node<'_>, + bytes: &[u8], + target: &str, +) -> Option<(HttpMethod, String)> { + let mut cur = args.walk(); + for c in args.named_children(&mut cur) { + if c.kind() != "object" { + continue; + } + let mut method: Option = None; + let mut url: Option = None; + let mut handler_matches = false; + let mut oc = c.walk(); + for pair in c.named_children(&mut oc) { + if pair.kind() != "pair" { + continue; + } + let Some(key) = pair.child_by_field_name("key").and_then(|n| n.utf8_text(bytes).ok()) + else { + continue; + }; + let Some(value) = pair.child_by_field_name("value") else { + continue; + }; + let key = key.trim_matches(['\'', '"', '`']); + match key { + "method" => { + let text = value.utf8_text(bytes).ok().unwrap_or(""); + method = http_verb_from_method(strip_quotes(text)); + } + "url" | "path" => { + let text = value.utf8_text(bytes).ok().unwrap_or(""); + url = Some(strip_quotes(text).to_owned()); + } + "handler" => { + if view_arg_references(value, bytes, target) { + handler_matches = true; + } + } + _ => {} + } + } + if handler_matches + && let Some(m) = method + && let Some(u) = url + { + return Some((m, u)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_js(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn extract_express_placeholders() { + assert_eq!(extract_path_placeholders("/users/:id"), vec!["id"]); + assert_eq!( + extract_path_placeholders("/u/:id/posts/:slug"), + vec!["id", "slug"] + ); + } + + #[test] + fn extract_brace_placeholders() { + assert_eq!(extract_path_placeholders("/users/{id}"), vec!["id"]); + assert_eq!(extract_path_placeholders("/users/{id?}"), vec!["id"]); + } + + #[test] + fn last_segment_strips_receiver() { + assert_eq!(last_segment("app.get"), "get"); + assert_eq!(last_segment("router.api.post"), "post"); + assert_eq!(last_segment("get"), "get"); + } + + #[test] + fn verb_dispatch_handles_aliases() { + assert_eq!(http_verb_from_method("GET"), Some(HttpMethod::GET)); + assert_eq!(http_verb_from_method("del"), Some(HttpMethod::DELETE)); + assert_eq!(http_verb_from_method("use"), None); + } + + #[test] + fn finds_function_declaration_params() { + let src: &[u8] = b"function handler(req, res) {}\n"; + let tree = parse_js(src); + let params = find_function_params(tree.root_node(), src, "handler").unwrap(); + let names = function_formal_names(params, src); + assert_eq!(names, vec!["req", "res"]); + } + + #[test] + fn finds_const_arrow_params() { + let src: &[u8] = b"const handler = (req, res, next) => {};\n"; + let tree = parse_js(src); + let params = find_function_params(tree.root_node(), src, "handler").unwrap(); + let names = function_formal_names(params, src); + assert_eq!(names, vec!["req", "res", "next"]); + } + + #[test] + fn bind_path_params_marks_implicit() { + let formals = vec!["req".to_owned(), "res".to_owned(), "next".to_owned()]; + let bound = bind_path_params(&formals, "/x"); + for b in &bound { + assert!(matches!(b.source, ParamSource::Implicit)); + } + } + + #[test] + fn find_route_registration_matches_app_get() { + let src: &[u8] = b"app.get('/users/:id', handler);\n"; + let tree = parse_js(src); + let recv = |n: &str| n == "app"; + let (method, path) = + find_route_registration(tree.root_node(), src, "handler", &recv).unwrap(); + assert_eq!(method, HttpMethod::GET); + assert_eq!(path, "/users/:id"); + } + + #[test] + fn find_route_registration_matches_middleware_chain() { + let src: &[u8] = b"app.post('/save', authz, validate, handler);\n"; + let tree = parse_js(src); + let recv = |n: &str| n == "app"; + let (method, path) = + find_route_registration(tree.root_node(), src, "handler", &recv).unwrap(); + assert_eq!(method, HttpMethod::POST); + assert_eq!(path, "/save"); + } + + #[test] + fn find_route_registration_matches_fastify_options_object() { + let src: &[u8] = + b"fastify.route({ method: 'PUT', url: '/users/:id', handler: handler });\n"; + let tree = parse_js(src); + let recv = |n: &str| n == "fastify"; + let (method, path) = + find_route_registration(tree.root_node(), src, "handler", &recv).unwrap(); + assert_eq!(method, HttpMethod::PUT); + assert_eq!(path, "/users/:id"); + } +} diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index 674952a2..9e445d57 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -20,7 +20,12 @@ pub mod header_ruby; pub mod header_rust; pub mod java_deserialize; pub mod java_thymeleaf; +pub mod js_express; +pub mod js_fastify; pub mod js_handlebars; +pub mod js_koa; +pub mod js_nest; +pub mod js_routes; pub mod ldap_php; pub mod ldap_python; pub mod ldap_spring; @@ -64,7 +69,11 @@ pub use header_ruby::HeaderRubyAdapter; pub use header_rust::HeaderRustAdapter; pub use java_deserialize::JavaDeserializeAdapter; pub use java_thymeleaf::JavaThymeleafAdapter; +pub use js_express::JsExpressAdapter; +pub use js_fastify::JsFastifyAdapter; pub use js_handlebars::JsHandlebarsAdapter; +pub use js_koa::JsKoaAdapter; +pub use js_nest::{JsNestAdapter, TsNestAdapter}; pub use ldap_php::LdapPhpAdapter; pub use ldap_python::LdapPythonAdapter; pub use ldap_spring::LdapSpringAdapter; diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index 8b97a092..5566d33e 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -214,13 +214,14 @@ mod tests { } #[test] - fn registry_baseline_after_phase_12() { - // Phase 12 (Track L.10) adds four Python framework adapters - // (`python-django`, `python-fastapi`, `python-flask`, - // `python-starlette`) to the Python slice, growing it from - // 7 → 11. Java / PHP keep their 7-entry J.1..J.7 stacks; - // Ruby keeps 5; Go keeps 3; Rust keeps 2; JavaScript keeps 7; - // TypeScript keeps 3. C / Cpp stay empty. + fn registry_baseline_after_phase_13() { + // Phase 13 (Track L.11) adds four JS framework adapters + // (`js-express`, `js-fastify`, `js-koa`, `js-nest`) to the + // JavaScript slice, growing it from 7 → 11; the TypeScript + // slice gains `ts-nest`, growing it from 3 → 4. Phase 12 + // (Track L.10) baseline for Python / Java / Php / Ruby / Go / + // Rust remains unchanged: Python 11, Java 7, Php 7, Ruby 5, + // Go 3, Rust 2. C / Cpp stay empty. for lang in [Lang::Java, Lang::Php] { let registered = registry::adapters_for(lang); assert_eq!( @@ -254,8 +255,8 @@ mod tests { let js_registered = registry::adapters_for(Lang::JavaScript); assert_eq!( js_registered.len(), - 7, - "JavaScript must have J.2 + J.5 + J.6 + J.7 + J.8(×3) adapters", + 11, + "JavaScript must have J.2 + J.5 + J.6 + J.7 + J.8(×3) + L.11(×4) adapters", ); for adapter in js_registered { assert_eq!(adapter.lang(), Lang::JavaScript); @@ -263,8 +264,8 @@ mod tests { let ts_registered = registry::adapters_for(Lang::TypeScript); assert_eq!( ts_registered.len(), - 3, - "TypeScript must have the J.8(×3) prototype-pollution adapters", + 4, + "TypeScript must have the J.8(×3) prototype-pollution adapters + L.11 ts-nest", ); for adapter in ts_registered { assert_eq!(adapter.lang(), Lang::TypeScript); diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs index 88d2e7e3..3e3047e0 100644 --- a/src/dynamic/framework/registry.rs +++ b/src/dynamic/framework/registry.rs @@ -97,10 +97,15 @@ static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[ &super::adapters::PpJsonDeepAssignTsAdapter, &super::adapters::PpLodashMergeTsAdapter, &super::adapters::PpObjectAssignTsAdapter, + &super::adapters::TsNestAdapter, ]; static JAVASCRIPT: &[&dyn FrameworkAdapter] = &[ &super::adapters::HeaderJsAdapter, + &super::adapters::JsExpressAdapter, + &super::adapters::JsFastifyAdapter, &super::adapters::JsHandlebarsAdapter, + &super::adapters::JsKoaAdapter, + &super::adapters::JsNestAdapter, &super::adapters::PpJsonDeepAssignJsAdapter, &super::adapters::PpLodashMergeJsAdapter, &super::adapters::PpObjectAssignJsAdapter, diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index e0fec72d..a33eeaed 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -54,6 +54,19 @@ pub enum JsShape { /// DOM event handler executed inside a `jsdom` window. Harness sets /// up `globalThis.window` / `document` and dispatches an event. BrowserEvent, + /// Fastify route plugin. Harness loads the entry's `app` export + /// (which must be a configured Fastify instance) and replays the + /// spec's request through Fastify's built-in + /// [`light-my-request`](https://github.com/fastify/light-my-request) + /// equivalent — `app.inject({ method, url, query, payload, headers })`. + /// No external `supertest` dep is required because `inject` ships in + /// Fastify core. Phase 13 — Track L.11. + Fastify, + /// NestJS controller class. Harness loads the entry's exported + /// controller class, mounts it via `Test.createTestingModule`, and + /// replays the spec's request through `supertest(app.getHttpServer())`. + /// Phase 13 — Track L.11. + Nest, } impl JsShape { @@ -72,6 +85,28 @@ impl JsShape { source, &["require('koa')", "require(\"koa\")", "from 'koa'", "from \"koa\""], ); + let has_fastify = source_has_marker( + source, + &[ + "require('fastify')", + "require(\"fastify\")", + "from 'fastify'", + "from \"fastify\"", + "// nyx-shape: fastify", + ], + ); + let has_nest = source_has_marker( + source, + &[ + "@nestjs/common", + "@nestjs/core", + "@nestjs/platform-express", + "@nestjs/platform-fastify", + "NestFactory", + "@Controller", + "// nyx-shape: nest", + ], + ); let has_next = source_has_marker( source, &["from 'next'", "from \"next\"", "NextApiRequest", "NextApiResponse", "// nyx-shape: next"], @@ -97,6 +132,16 @@ impl JsShape { &["export default ", "// nyx-shape: esm-default"], ); + // Nest wins over Express / Fastify because Nest projects also + // import `@nestjs/platform-express` / `@nestjs/platform-fastify` + // transitively — the controller-class shape needs its own + // testing module bootstrap. + if has_nest { + return Self::Nest; + } + if has_fastify { + return Self::Fastify; + } if has_express { return Self::Express; } @@ -402,6 +447,31 @@ fn extra_files_for_shape(shape: JsShape) -> Vec<(String, String)> { ("package.json".to_owned(), package_json_for("jsdom", "^24.1.1")), ("package-lock.json".to_owned(), package_lock_skeleton("nyx-harness-jsdom")), ], + JsShape::Fastify => vec![ + ("package.json".to_owned(), package_json_for("fastify", "^4.28.1")), + ("package-lock.json".to_owned(), package_lock_skeleton("nyx-harness-fastify")), + ], + JsShape::Nest => vec![ + ( + "package.json".to_owned(), + package_json_multi( + "nyx-harness-nest", + &[ + ("@nestjs/common", "^10.0.0"), + ("@nestjs/core", "^10.0.0"), + ("@nestjs/platform-express", "^10.0.0"), + ("@nestjs/testing", "^10.0.0"), + ("supertest", "^7.0.0"), + ("reflect-metadata", "^0.2.0"), + ("rxjs", "^7.8.0"), + ], + ), + ), + ( + "package-lock.json".to_owned(), + package_lock_skeleton("nyx-harness-nest"), + ), + ], // Plain async / CJS / ESM use stdlib only. _ => vec![], } @@ -413,6 +483,26 @@ fn package_json_for(dep: &str, version: &str) -> String { ) } +fn package_json_multi(pkg_name: &str, deps: &[(&str, &str)]) -> String { + let mut body = String::with_capacity(128); + body.push_str("{\n \"name\": \""); + body.push_str(pkg_name); + body.push_str("\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"dependencies\": {\n"); + for (i, (name, ver)) in deps.iter().enumerate() { + body.push_str(" \""); + body.push_str(name); + body.push_str("\": \""); + body.push_str(ver); + body.push('"'); + if i + 1 != deps.len() { + body.push(','); + } + body.push('\n'); + } + body.push_str(" }\n}\n"); + body +} + fn package_lock_skeleton(name: &str) -> String { // Bare lockfile structure. npm rewrites this on first install; checking // it in keeps the per-shape fixture directory self-describing. @@ -980,6 +1070,8 @@ fn generate_for_shape(spec: &HarnessSpec, shape: JsShape, entry_subpath: &str) - JsShape::Koa => emit_koa(spec), JsShape::NextRoute => emit_next(spec), JsShape::BrowserEvent => emit_browser_event(spec), + JsShape::Fastify => emit_fastify(spec), + JsShape::Nest => emit_nest(spec), }; format!("{preamble}\n{body}\n") } @@ -1260,6 +1352,140 @@ const _res = {{ ) } +/// Phase 13 — Track L.11 Fastify harness. +/// +/// Loads the entry's `app` export (the configured Fastify instance) +/// and replays the spec's request through Fastify's built-in +/// [`light-my-request`](https://github.com/fastify/light-my-request) +/// equivalent — `app.inject({ method, url, query, payload, headers })`. +/// No external `supertest` dep is required because `inject` ships in +/// Fastify core. +fn emit_fastify(spec: &HarnessSpec) -> String { + let (method, payload_key, body_kind) = resolve_http_payload(&spec.payload_slot); + format!( + r#"// Shape: Fastify route — boot via app.inject() (light-my-request equivalent). +const _app = _entry.app || _entry.default || _entry; +if (!_app || typeof _app.inject !== 'function') {{ + process.stderr.write('NYX_FASTIFY_APP_NOT_FOUND\n'); + process.exit(78); +}} +const _kind = {body_kind:?}; +const _payload_key = {payload_key:?}; +const _method = {method:?}; +let _path = '/'; +let _query; +let _bodyArg = undefined; +let _headers = {{}}; +if (_kind === 'query') {{ + _query = {{}}; + _query[_payload_key] = payload; +}} else if (_kind === 'body') {{ + _bodyArg = payload; + _headers['content-type'] = 'application/json'; +}} else if (_kind === 'env') {{ + process.env[_payload_key] = payload; +}} else if (_kind === 'param') {{ + _path = '/' + encodeURIComponent(payload); +}} +(async () => {{ + try {{ + if (typeof _app.ready === 'function') await _app.ready(); + const _injectOpts = {{ method: _method, url: _path, headers: _headers }}; + if (_query) _injectOpts.query = _query; + if (_bodyArg !== undefined) _injectOpts.payload = _bodyArg; + const _res = await _app.inject(_injectOpts); + process.stdout.write(String(_res.body == null ? '' : _res.body) + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"# + ) +} + +/// Phase 13 — Track L.11 NestJS harness. +/// +/// Loads the entry's exported controller class (`_entry.Controller` +/// / `_entry.default`), mounts it via +/// `Test.createTestingModule({controllers:[Controller]}).compile()`, +/// boots the Nest application, and replays the spec's request through +/// `supertest(app.getHttpServer())`. Falls back to `_entry.app` +/// (already-built Nest app instance) when the fixture pre-mounts +/// itself. The `supertest` dep is bundled by `extra_files_for_shape`. +fn emit_nest(spec: &HarnessSpec) -> String { + let entry_fn = &spec.entry_name; + let (method, payload_key, body_kind) = resolve_http_payload(&spec.payload_slot); + let method_lower = method.to_ascii_lowercase(); + format!( + r#"// Shape: NestJS controller — boot via Test.createTestingModule + supertest. +require('reflect-metadata'); +let _supertest; +try {{ + _supertest = require('supertest'); +}} catch (e) {{ + process.stderr.write('NYX_SUPERTEST_MISSING: ' + e.message + '\n'); + process.exit(79); +}} +let _NestTesting; +try {{ + _NestTesting = require('@nestjs/testing'); +}} catch (e) {{ + process.stderr.write('NYX_NESTJS_TESTING_MISSING: ' + e.message + '\n'); + process.exit(79); +}} +const _kind = {body_kind:?}; +const _payload_key = {payload_key:?}; +const _method_lc = {method_lower:?}; +const _entry_name = {entry_fn:?}; +let _path = '/'; +if (_kind === 'env') {{ + process.env[_payload_key] = payload; +}} else if (_kind === 'param') {{ + _path = '/' + encodeURIComponent(payload); +}} +(async () => {{ + try {{ + let _app = _entry.app || (_entry.default && _entry.default.app); + if (!_app) {{ + // Locate a controller class — first @Controller / class export. + const _candidate = _entry[_entry_name] + || _entry.default + || _entry.AppController + || _entry.Controller + || Object.values(_entry).find((v) => typeof v === 'function'); + if (typeof _candidate !== 'function') {{ + process.stderr.write('NYX_NEST_CONTROLLER_NOT_FOUND\n'); + process.exit(78); + }} + const _module = await _NestTesting.Test + .createTestingModule({{ controllers: [_candidate] }}) + .compile(); + _app = _module.createNestApplication(); + await _app.init(); + }} + const _server = (typeof _app.getHttpServer === 'function') + ? _app.getHttpServer() + : _app; + const _agent = _supertest(_server); + let _req = _agent[_method_lc](_path); + if (_kind === 'query') {{ + const _q = {{}}; + _q[_payload_key] = payload; + _req = _req.query(_q); + }} else if (_kind === 'body') {{ + _req = _req.set('content-type', 'application/json').send(payload); + }} + const _res = await _req; + process.stdout.write(String(_res.text == null ? '' : _res.text) + '\n'); + if (typeof _app.close === 'function') await _app.close(); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"# + ) +} + fn emit_browser_event(spec: &HarnessSpec) -> String { let entry_fn = &spec.entry_name; let (pre_call, call_args) = build_call_args(spec); diff --git a/tests/dynamic_fixtures/js_frameworks/express/benign.js b/tests/dynamic_fixtures/js_frameworks/express/benign.js new file mode 100644 index 00000000..d5ff77ac --- /dev/null +++ b/tests/dynamic_fixtures/js_frameworks/express/benign.js @@ -0,0 +1,28 @@ +// Phase 13 (Track L.11) — Express CMDI benign fixture. +// +// The `/run` route accepts a `cmd` query parameter but rejects +// everything outside an allowlist before invoking `child_process.exec` +// with a fixed argv, so the sink call is unreachable for +// attacker-controlled values. + +const express = require('express'); +const { execFile } = require('child_process'); + +const app = express(); + +const ALLOW = new Set(['status', 'uptime', 'version']); + +function runCmd(req, res) { + const cmd = req.query.cmd || ''; + if (!ALLOW.has(cmd)) { + return res.status(400).send('rejected'); + } + execFile('/usr/bin/echo', [cmd], (err, stdout) => { + if (err) return res.status(500).send(String(err)); + res.send(stdout); + }); +} + +app.get('/run', runCmd); + +module.exports = { app, runCmd }; diff --git a/tests/dynamic_fixtures/js_frameworks/express/vuln.js b/tests/dynamic_fixtures/js_frameworks/express/vuln.js new file mode 100644 index 00000000..3c8952e3 --- /dev/null +++ b/tests/dynamic_fixtures/js_frameworks/express/vuln.js @@ -0,0 +1,23 @@ +// Phase 13 (Track L.11) — Express CMDI vuln fixture. +// +// The `/run` route forwards a `cmd` query parameter straight into +// `child_process.exec`, so any attacker who reaches the route can +// execute arbitrary shell. Adapter binding: +// `app.get('/run', runCmd)` with `cmd` flowing through `req.query.cmd`. + +const express = require('express'); +const { exec } = require('child_process'); + +const app = express(); + +function runCmd(req, res) { + const cmd = req.query.cmd || ''; + exec(cmd, (err, stdout) => { + if (err) return res.status(500).send(String(err)); + res.send(stdout); + }); +} + +app.get('/run', runCmd); + +module.exports = { app, runCmd }; diff --git a/tests/dynamic_fixtures/js_frameworks/fastify/benign.js b/tests/dynamic_fixtures/js_frameworks/fastify/benign.js new file mode 100644 index 00000000..bcb5dedc --- /dev/null +++ b/tests/dynamic_fixtures/js_frameworks/fastify/benign.js @@ -0,0 +1,28 @@ +// Phase 13 (Track L.11) — Fastify CMDI benign fixture. +// +// The `/run` route accepts a `cmd` query parameter but rejects +// everything outside an allowlist before invoking +// `child_process.execFile` with a fixed argv. + +const fastify = require('fastify')(); +const { execFile } = require('child_process'); + +const ALLOW = new Set(['status', 'uptime', 'version']); + +async function runCmd(request, reply) { + const cmd = request.query.cmd || ''; + if (!ALLOW.has(cmd)) { + reply.code(400).send('rejected'); + return; + } + const out = await new Promise((resolve) => { + execFile('/usr/bin/echo', [cmd], (err, stdout) => { + resolve(err ? String(err) : stdout); + }); + }); + reply.send(out); +} + +fastify.get('/run', runCmd); + +module.exports = { app: fastify, runCmd }; diff --git a/tests/dynamic_fixtures/js_frameworks/fastify/vuln.js b/tests/dynamic_fixtures/js_frameworks/fastify/vuln.js new file mode 100644 index 00000000..8ab4aacb --- /dev/null +++ b/tests/dynamic_fixtures/js_frameworks/fastify/vuln.js @@ -0,0 +1,20 @@ +// Phase 13 (Track L.11) — Fastify CMDI vuln fixture. +// +// The `/run` route forwards a `cmd` query parameter straight into +// `child_process.exec`. Adapter binding: `fastify.get('/run', runCmd)` +// with `cmd` flowing through `request.query.cmd`. + +const fastify = require('fastify')(); +const { exec } = require('child_process'); + +async function runCmd(request, reply) { + const cmd = request.query.cmd || ''; + const out = await new Promise((resolve) => { + exec(cmd, (err, stdout) => resolve(err ? String(err) : stdout)); + }); + reply.send(out); +} + +fastify.get('/run', runCmd); + +module.exports = { app: fastify, runCmd }; diff --git a/tests/dynamic_fixtures/js_frameworks/koa/benign.js b/tests/dynamic_fixtures/js_frameworks/koa/benign.js new file mode 100644 index 00000000..cab97586 --- /dev/null +++ b/tests/dynamic_fixtures/js_frameworks/koa/benign.js @@ -0,0 +1,34 @@ +// Phase 13 (Track L.11) — Koa CMDI benign fixture. +// +// The `/run` route accepts a `cmd` query parameter but rejects +// everything outside an allowlist before invoking `child_process.execFile` +// with a fixed argv. + +const Koa = require('koa'); +const Router = require('@koa/router'); +const { execFile } = require('child_process'); + +const app = new Koa(); +const router = new Router(); + +const ALLOW = new Set(['status', 'uptime', 'version']); + +async function runCmd(ctx) { + const cmd = ctx.query.cmd || ''; + if (!ALLOW.has(cmd)) { + ctx.status = 400; + ctx.body = 'rejected'; + return; + } + await new Promise((resolve) => { + execFile('/usr/bin/echo', [cmd], (err, stdout) => { + ctx.body = err ? String(err) : stdout; + resolve(); + }); + }); +} + +router.get('/run', runCmd); +app.use(router.routes()); + +module.exports = { app, runCmd }; diff --git a/tests/dynamic_fixtures/js_frameworks/koa/vuln.js b/tests/dynamic_fixtures/js_frameworks/koa/vuln.js new file mode 100644 index 00000000..d1f458b3 --- /dev/null +++ b/tests/dynamic_fixtures/js_frameworks/koa/vuln.js @@ -0,0 +1,27 @@ +// Phase 13 (Track L.11) — Koa CMDI vuln fixture. +// +// The `/run` route forwards a `cmd` query parameter straight into +// `child_process.exec`. Adapter binding: `router.get('/run', runCmd)` +// with `cmd` flowing through `ctx.query.cmd`. + +const Koa = require('koa'); +const Router = require('@koa/router'); +const { exec } = require('child_process'); + +const app = new Koa(); +const router = new Router(); + +async function runCmd(ctx) { + const cmd = ctx.query.cmd || ''; + await new Promise((resolve) => { + exec(cmd, (err, stdout) => { + ctx.body = err ? String(err) : stdout; + resolve(); + }); + }); +} + +router.get('/run', runCmd); +app.use(router.routes()); + +module.exports = { app, runCmd }; diff --git a/tests/dynamic_fixtures/js_frameworks/nest/benign.js b/tests/dynamic_fixtures/js_frameworks/nest/benign.js new file mode 100644 index 00000000..ed8f2c7e --- /dev/null +++ b/tests/dynamic_fixtures/js_frameworks/nest/benign.js @@ -0,0 +1,26 @@ +// Phase 13 (Track L.11) — NestJS CMDI benign fixture. Same adapter +// binding shape as the vuln fixture; the differential outcome is what +// distinguishes the two. + +require('reflect-metadata'); +const { Controller, Get, Query } = require('@nestjs/common'); +const { execFile } = require('child_process'); + +const ALLOW = new Set(['status', 'uptime', 'version']); + +@Controller('') +class AppController { + @Get('run') + runCmd(@Query('cmd') cmd) { + if (!ALLOW.has(cmd || '')) { + return 'rejected'; + } + return new Promise((resolve) => { + execFile('/usr/bin/echo', [cmd], (err, stdout) => { + resolve(err ? String(err) : stdout); + }); + }); + } +} + +module.exports = { AppController }; diff --git a/tests/dynamic_fixtures/js_frameworks/nest/vuln.js b/tests/dynamic_fixtures/js_frameworks/nest/vuln.js new file mode 100644 index 00000000..f7b559b0 --- /dev/null +++ b/tests/dynamic_fixtures/js_frameworks/nest/vuln.js @@ -0,0 +1,27 @@ +// Phase 13 (Track L.11) — NestJS CMDI vuln fixture (Babel-stage-1 +// decorator syntax form). Real Nest projects publish their +// controllers either as `.ts` files or as Babel-transpiled `.js` +// carrying the inline decorator syntax via `@babel/plugin-proposal-decorators` +// + `reflect-metadata`. The adapter binds the decorator syntax; +// the harness loads the entry via `Test.createTestingModule`. +// +// Adapter binding: `@Controller('')` + `@Get('run')` on +// `AppController.runCmd` with `cmd` flowing through `@Query('cmd')`. + +require('reflect-metadata'); +const { Controller, Get, Query } = require('@nestjs/common'); +const { exec } = require('child_process'); + +@Controller('') +class AppController { + @Get('run') + runCmd(@Query('cmd') cmd) { + return new Promise((resolve) => { + exec(cmd || '', (err, stdout) => { + resolve(err ? String(err) : stdout); + }); + }); + } +} + +module.exports = { AppController }; diff --git a/tests/dynamic_fixtures/ts_frameworks/express/benign.ts b/tests/dynamic_fixtures/ts_frameworks/express/benign.ts new file mode 100644 index 00000000..23f51164 --- /dev/null +++ b/tests/dynamic_fixtures/ts_frameworks/express/benign.ts @@ -0,0 +1,27 @@ +// Phase 13 (Track L.11) — Express CMDI benign fixture (TypeScript). + +import express, { Request, Response } from 'express'; +import { execFile } from 'child_process'; + +const app = express(); + +const ALLOW = new Set(['status', 'uptime', 'version']); + +function runCmd(req: Request, res: Response) { + const cmd = (req.query.cmd as string) || ''; + if (!ALLOW.has(cmd)) { + res.status(400).send('rejected'); + return; + } + execFile('/usr/bin/echo', [cmd], (err, stdout) => { + if (err) { + res.status(500).send(String(err)); + return; + } + res.send(stdout); + }); +} + +app.get('/run', runCmd); + +export { app, runCmd }; diff --git a/tests/dynamic_fixtures/ts_frameworks/express/vuln.ts b/tests/dynamic_fixtures/ts_frameworks/express/vuln.ts new file mode 100644 index 00000000..5357f057 --- /dev/null +++ b/tests/dynamic_fixtures/ts_frameworks/express/vuln.ts @@ -0,0 +1,23 @@ +// Phase 13 (Track L.11) — Express CMDI vuln fixture (TypeScript). +// Same shape as the JS twin; binds `app.get('/run', runCmd)` and +// flows `req.query.cmd` straight into `exec`. + +import express, { Request, Response } from 'express'; +import { exec } from 'child_process'; + +const app = express(); + +function runCmd(req: Request, res: Response) { + const cmd = (req.query.cmd as string) || ''; + exec(cmd, (err, stdout) => { + if (err) { + res.status(500).send(String(err)); + return; + } + res.send(stdout); + }); +} + +app.get('/run', runCmd); + +export { app, runCmd }; diff --git a/tests/dynamic_fixtures/ts_frameworks/fastify/benign.ts b/tests/dynamic_fixtures/ts_frameworks/fastify/benign.ts new file mode 100644 index 00000000..572f64a4 --- /dev/null +++ b/tests/dynamic_fixtures/ts_frameworks/fastify/benign.ts @@ -0,0 +1,25 @@ +// Phase 13 (Track L.11) — Fastify CMDI benign fixture (TypeScript). + +import Fastify, { FastifyRequest, FastifyReply } from 'fastify'; +import { execFile } from 'child_process'; + +const app = Fastify(); +const ALLOW = new Set(['status', 'uptime', 'version']); + +async function runCmd(request: FastifyRequest, reply: FastifyReply): Promise { + const cmd = ((request.query as Record).cmd) || ''; + if (!ALLOW.has(cmd)) { + reply.code(400).send('rejected'); + return; + } + const out = await new Promise((resolve) => { + execFile('/usr/bin/echo', [cmd], (err, stdout) => { + resolve(err ? String(err) : stdout); + }); + }); + reply.send(out); +} + +app.get('/run', runCmd); + +export { app, runCmd }; diff --git a/tests/dynamic_fixtures/ts_frameworks/fastify/vuln.ts b/tests/dynamic_fixtures/ts_frameworks/fastify/vuln.ts new file mode 100644 index 00000000..7d8cafc8 --- /dev/null +++ b/tests/dynamic_fixtures/ts_frameworks/fastify/vuln.ts @@ -0,0 +1,18 @@ +// Phase 13 (Track L.11) — Fastify CMDI vuln fixture (TypeScript). + +import Fastify, { FastifyRequest, FastifyReply } from 'fastify'; +import { exec } from 'child_process'; + +const app = Fastify(); + +async function runCmd(request: FastifyRequest, reply: FastifyReply): Promise { + const cmd = ((request.query as Record).cmd) || ''; + const out = await new Promise((resolve) => { + exec(cmd, (err, stdout) => resolve(err ? String(err) : stdout)); + }); + reply.send(out); +} + +app.get('/run', runCmd); + +export { app, runCmd }; diff --git a/tests/dynamic_fixtures/ts_frameworks/koa/benign.ts b/tests/dynamic_fixtures/ts_frameworks/koa/benign.ts new file mode 100644 index 00000000..89ad3a89 --- /dev/null +++ b/tests/dynamic_fixtures/ts_frameworks/koa/benign.ts @@ -0,0 +1,29 @@ +// Phase 13 (Track L.11) — Koa CMDI benign fixture (TypeScript). + +import Koa from 'koa'; +import Router from '@koa/router'; +import { execFile } from 'child_process'; + +const app = new Koa(); +const router = new Router(); +const ALLOW = new Set(['status', 'uptime', 'version']); + +async function runCmd(ctx: Koa.Context): Promise { + const cmd = (ctx.query.cmd as string) || ''; + if (!ALLOW.has(cmd)) { + ctx.status = 400; + ctx.body = 'rejected'; + return; + } + await new Promise((resolve) => { + execFile('/usr/bin/echo', [cmd], (err, stdout) => { + ctx.body = err ? String(err) : stdout; + resolve(); + }); + }); +} + +router.get('/run', runCmd); +app.use(router.routes()); + +export { app, runCmd }; diff --git a/tests/dynamic_fixtures/ts_frameworks/koa/vuln.ts b/tests/dynamic_fixtures/ts_frameworks/koa/vuln.ts new file mode 100644 index 00000000..26d67a0d --- /dev/null +++ b/tests/dynamic_fixtures/ts_frameworks/koa/vuln.ts @@ -0,0 +1,23 @@ +// Phase 13 (Track L.11) — Koa CMDI vuln fixture (TypeScript). + +import Koa from 'koa'; +import Router from '@koa/router'; +import { exec } from 'child_process'; + +const app = new Koa(); +const router = new Router(); + +async function runCmd(ctx: Koa.Context): Promise { + const cmd = (ctx.query.cmd as string) || ''; + await new Promise((resolve) => { + exec(cmd, (err, stdout) => { + ctx.body = err ? String(err) : stdout; + resolve(); + }); + }); +} + +router.get('/run', runCmd); +app.use(router.routes()); + +export { app, runCmd }; diff --git a/tests/dynamic_fixtures/ts_frameworks/nest/benign.ts b/tests/dynamic_fixtures/ts_frameworks/nest/benign.ts new file mode 100644 index 00000000..f2e7838c --- /dev/null +++ b/tests/dynamic_fixtures/ts_frameworks/nest/benign.ts @@ -0,0 +1,22 @@ +// Phase 13 (Track L.11) — NestJS CMDI benign fixture (TypeScript). + +import 'reflect-metadata'; +import { Controller, Get, Query } from '@nestjs/common'; +import { execFile } from 'child_process'; + +const ALLOW = new Set(['status', 'uptime', 'version']); + +@Controller('') +export class AppController { + @Get('run') + runCmd(@Query('cmd') cmd: string): Promise | string { + if (!ALLOW.has(cmd || '')) { + return 'rejected'; + } + return new Promise((resolve) => { + execFile('/usr/bin/echo', [cmd], (err, stdout) => { + resolve(err ? String(err) : stdout); + }); + }); + } +} diff --git a/tests/dynamic_fixtures/ts_frameworks/nest/vuln.ts b/tests/dynamic_fixtures/ts_frameworks/nest/vuln.ts new file mode 100644 index 00000000..b4afe880 --- /dev/null +++ b/tests/dynamic_fixtures/ts_frameworks/nest/vuln.ts @@ -0,0 +1,20 @@ +// Phase 13 (Track L.11) — NestJS CMDI vuln fixture (TypeScript). +// +// Adapter binding: `@Controller('')` + `@Get('run')` on +// `AppController.runCmd` with `cmd` flowing through `@Query('cmd')`. + +import 'reflect-metadata'; +import { Controller, Get, Query } from '@nestjs/common'; +import { exec } from 'child_process'; + +@Controller('') +export class AppController { + @Get('run') + runCmd(@Query('cmd') cmd: string): Promise { + return new Promise((resolve) => { + exec(cmd || '', (err, stdout) => { + resolve(err ? String(err) : stdout); + }); + }); + } +} diff --git a/tests/js_frameworks_corpus.rs b/tests/js_frameworks_corpus.rs new file mode 100644 index 00000000..fc35111d --- /dev/null +++ b/tests/js_frameworks_corpus.rs @@ -0,0 +1,182 @@ +//! Phase 13 (Track L.11) — JS framework adapter integration tests. +//! +//! Each test exercises `detect_binding` end-to-end against a fixture +//! file under `tests/dynamic_fixtures/js_frameworks/`, asserting that +//! the right adapter fires, the binding carries +//! `EntryKind::HttpRoute`, and the `RouteShape` + per-formal +//! `request_params` match the brief's contract. Benign fixtures must +//! produce the same adapter binding shape as the vuln fixtures — the +//! adapter only models the route, the differential outcome of a +//! verifier run is what distinguishes the two. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::framework::{detect_binding, HttpMethod, ParamSource}; +use nyx_scanner::evidence::EntryKind; +use nyx_scanner::summary::FuncSummary; +use nyx_scanner::symbol::Lang; + +fn parse_js(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() +} + +fn summary_for(name: &str, file: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + file_path: file.into(), + lang: "javascript".into(), + ..Default::default() + } +} + +#[test] +fn express_vuln_fixture_binds_route() { + let path = "tests/dynamic_fixtures/js_frameworks/express/vuln.js"; + let bytes = std::fs::read(path).expect("express vuln fixture exists"); + let tree = parse_js(&bytes); + let summary = summary_for("runCmd", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript) + .expect("express adapter must bind"); + assert_eq!(binding.adapter, "js-express"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); + assert!(binding + .request_params + .iter() + .any(|p| p.name == "req" && matches!(p.source, ParamSource::Implicit))); +} + +#[test] +fn express_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/js_frameworks/express/benign.js"; + let bytes = std::fs::read(path).expect("express benign fixture exists"); + let tree = parse_js(&bytes); + let summary = summary_for("runCmd", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript) + .expect("express adapter must bind benign fixture"); + assert_eq!(binding.adapter, "js-express"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); +} + +#[test] +fn koa_vuln_fixture_binds_router_route() { + let path = "tests/dynamic_fixtures/js_frameworks/koa/vuln.js"; + let bytes = std::fs::read(path).expect("koa vuln fixture exists"); + let tree = parse_js(&bytes); + let summary = summary_for("runCmd", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript) + .expect("koa adapter must bind"); + assert_eq!(binding.adapter, "js-koa"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); + assert!(binding + .request_params + .iter() + .any(|p| p.name == "ctx" && matches!(p.source, ParamSource::Implicit))); +} + +#[test] +fn koa_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/js_frameworks/koa/benign.js"; + let bytes = std::fs::read(path).expect("koa benign fixture exists"); + let tree = parse_js(&bytes); + let summary = summary_for("runCmd", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript) + .expect("koa adapter must bind benign fixture"); + assert_eq!(binding.adapter, "js-koa"); + assert_eq!(binding.route.as_ref().unwrap().path, "/run"); +} + +#[test] +fn fastify_vuln_fixture_binds_route() { + let path = "tests/dynamic_fixtures/js_frameworks/fastify/vuln.js"; + let bytes = std::fs::read(path).expect("fastify vuln fixture exists"); + let tree = parse_js(&bytes); + let summary = summary_for("runCmd", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript) + .expect("fastify adapter must bind"); + assert_eq!(binding.adapter, "js-fastify"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); + assert!(binding + .request_params + .iter() + .any(|p| p.name == "request" && matches!(p.source, ParamSource::Implicit))); + assert!(binding + .request_params + .iter() + .any(|p| p.name == "reply" && matches!(p.source, ParamSource::Implicit))); +} + +#[test] +fn fastify_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/js_frameworks/fastify/benign.js"; + let bytes = std::fs::read(path).expect("fastify benign fixture exists"); + let tree = parse_js(&bytes); + let summary = summary_for("runCmd", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript) + .expect("fastify adapter must bind benign fixture"); + assert_eq!(binding.adapter, "js-fastify"); + assert_eq!(binding.route.as_ref().unwrap().path, "/run"); +} + +#[test] +fn nest_vuln_fixture_binds_controller_route() { + let path = "tests/dynamic_fixtures/js_frameworks/nest/vuln.js"; + let bytes = std::fs::read(path).expect("nest vuln fixture exists"); + let tree = parse_js(&bytes); + let summary = summary_for("runCmd", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript) + .expect("nest adapter must bind"); + assert_eq!(binding.adapter, "js-nest"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); + let cmd_binding = binding + .request_params + .iter() + .find(|p| p.name == "cmd") + .expect("cmd formal"); + match &cmd_binding.source { + ParamSource::QueryParam(q) => assert_eq!(q, "cmd"), + other => panic!("expected QueryParam(\"cmd\"), got {other:?}"), + } +} + +#[test] +fn nest_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/js_frameworks/nest/benign.js"; + let bytes = std::fs::read(path).expect("nest benign fixture exists"); + let tree = parse_js(&bytes); + let summary = summary_for("runCmd", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript) + .expect("nest adapter must bind benign fixture"); + assert_eq!(binding.adapter, "js-nest"); + assert_eq!(binding.route.as_ref().unwrap().path, "/run"); +} + +#[test] +fn express_adapter_runs_before_fastify_for_express_files() { + // Regression guard: an Express file does not pull in `fastify`, + // so the Fastify adapter never fires. Registration order is + // alphabetical (`js-express` before `js-fastify`) which keeps the + // adapter dispatch deterministic. + let src: &[u8] = b"const express = require('express');\n\ + const app = express();\n\ + function h(req, res) { res.send('ok'); }\n\ + app.get('/x', h);\n"; + let tree = parse_js(src); + let summary = summary_for("h", "synthetic.js"); + let binding = + detect_binding(&summary, tree.root_node(), src, Lang::JavaScript).expect("fires"); + assert_eq!(binding.adapter, "js-express"); +} diff --git a/tests/ts_frameworks_corpus.rs b/tests/ts_frameworks_corpus.rs new file mode 100644 index 00000000..5e726730 --- /dev/null +++ b/tests/ts_frameworks_corpus.rs @@ -0,0 +1,68 @@ +//! Phase 13 (Track L.11) — TypeScript framework adapter integration tests. +//! +//! Mirrors `tests/js_frameworks_corpus.rs` against the TS fixtures. +//! The Express / Koa / Fastify adapters are registered under +//! [`Lang::JavaScript`] only (TypeScript code paths share the JS +//! adapter via the Lang dispatch); the Nest adapter is registered +//! under both [`Lang::JavaScript`] and [`Lang::TypeScript`] because +//! Nest is TypeScript-first. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::framework::{detect_binding, HttpMethod, ParamSource}; +use nyx_scanner::evidence::EntryKind; +use nyx_scanner::summary::FuncSummary; +use nyx_scanner::symbol::Lang; + +fn parse_ts(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = + tree_sitter::Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() +} + +fn summary_for(name: &str, file: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + file_path: file.into(), + lang: "typescript".into(), + ..Default::default() + } +} + +#[test] +fn nest_ts_vuln_fixture_binds_controller_route() { + let path = "tests/dynamic_fixtures/ts_frameworks/nest/vuln.ts"; + let bytes = std::fs::read(path).expect("nest TS vuln fixture exists"); + let tree = parse_ts(&bytes); + let summary = summary_for("runCmd", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::TypeScript) + .expect("ts-nest adapter must bind"); + assert_eq!(binding.adapter, "ts-nest"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); + let cmd_binding = binding + .request_params + .iter() + .find(|p| p.name == "cmd") + .expect("cmd formal"); + match &cmd_binding.source { + ParamSource::QueryParam(q) => assert_eq!(q, "cmd"), + other => panic!("expected QueryParam, got {other:?}"), + } +} + +#[test] +fn nest_ts_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/ts_frameworks/nest/benign.ts"; + let bytes = std::fs::read(path).expect("nest TS benign fixture exists"); + let tree = parse_ts(&bytes); + let summary = summary_for("runCmd", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::TypeScript) + .expect("ts-nest adapter must bind benign fixture"); + assert_eq!(binding.adapter, "ts-nest"); + assert_eq!(binding.route.as_ref().unwrap().path, "/run"); +}