From 2548a4a802cf8d375275710bd17f22131bb125ea Mon Sep 17 00:00:00 2001 From: pitboss Date: Thu, 21 May 2026 17:34:13 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0009 (20260521T201327Z-3848) --- src/dynamic/framework/adapters/js_fastify.rs | 54 +++++- src/dynamic/framework/adapters/js_koa.rs | 41 ++++- src/dynamic/framework/adapters/js_nest.rs | 172 ++++++++++++++++++- src/dynamic/framework/adapters/js_routes.rs | 121 +++++++++++++ 4 files changed, 379 insertions(+), 9 deletions(-) diff --git a/src/dynamic/framework/adapters/js_fastify.rs b/src/dynamic/framework/adapters/js_fastify.rs index 3889fec7..d34c9a17 100644 --- a/src/dynamic/framework/adapters/js_fastify.rs +++ b/src/dynamic/framework/adapters/js_fastify.rs @@ -21,8 +21,8 @@ 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, + bind_path_params, extract_route_middleware, find_function_params, find_route_registration, + function_formal_names, source_imports_fastify, }; pub struct JsFastifyAdapter; @@ -63,13 +63,14 @@ impl FrameworkAdapter for JsFastifyAdapter { .map(|p| function_formal_names(p, file_bytes)) .unwrap_or_default(); let request_params = bind_path_params(&formals, &path); + let middleware = extract_route_middleware(ast, file_bytes, &summary.name, &recv); Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::HttpRoute, route: Some(RouteShape { method, path }), request_params, response_writer: None, - middleware: Vec::new(), + middleware, }) } } @@ -141,6 +142,53 @@ mod tests { assert_eq!(binding.route.unwrap().path, "/inner"); } + #[test] + fn records_chained_middleware_and_global_use() { + let src: &[u8] = b"const fastify = require('fastify')();\n\ + fastify.use(helmet());\n\ + function authz(request, reply, done) { done(); }\n\ + function handler(request, reply) { reply.send('ok'); }\n\ + fastify.post('/save', authz, handler);\n"; + let tree = parse_js(src); + let binding = JsFastifyAdapter + .detect(&summary("handler"), tree.root_node(), src) + .expect("binding"); + let names: Vec<_> = binding.middleware.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["helmet", "authz"]); + } + + #[test] + fn records_options_object_pre_handler_hooks() { + let src: &[u8] = b"const fastify = require('fastify')();\n\ + async function handler(request, reply) { reply.send('ok'); }\n\ + fastify.route({\n\ + method: 'PUT',\n\ + url: '/items/:id',\n\ + onRequest: tokenAuth,\n\ + preHandler: [authz, validate],\n\ + handler: handler,\n\ + });\n"; + let tree = parse_js(src); + let binding = JsFastifyAdapter + .detect(&summary("handler"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.as_ref().unwrap().method, HttpMethod::PUT); + let names: Vec<_> = binding.middleware.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["tokenAuth", "authz", "validate"]); + } + + #[test] + fn middleware_empty_when_route_has_no_chain() { + let src: &[u8] = b"const fastify = require('fastify')();\n\ + function handler(request, reply) { reply.send('ok'); }\n\ + fastify.get('/x', handler);\n"; + let tree = parse_js(src); + let binding = JsFastifyAdapter + .detect(&summary("handler"), tree.root_node(), src) + .expect("binding"); + assert!(binding.middleware.is_empty()); + } + #[test] fn skips_when_fastify_not_imported() { let src: &[u8] = b"const express = require('express');\n\ diff --git a/src/dynamic/framework/adapters/js_koa.rs b/src/dynamic/framework/adapters/js_koa.rs index 5be0d332..aab8bff3 100644 --- a/src/dynamic/framework/adapters/js_koa.rs +++ b/src/dynamic/framework/adapters/js_koa.rs @@ -17,8 +17,8 @@ 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, + bind_path_params, extract_route_middleware, find_function_params, find_route_registration, + function_formal_names, last_segment, source_imports_koa, view_arg_references, }; pub struct JsKoaAdapter; @@ -102,13 +102,14 @@ impl FrameworkAdapter for JsKoaAdapter { if let Some((method, path)) = find_route_registration(ast, file_bytes, &summary.name, &recv) { let request_params = formals_for(&path); + let middleware = extract_route_middleware(ast, file_bytes, &summary.name, &recv); return Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::HttpRoute, route: Some(RouteShape { method, path }), request_params, response_writer: None, - middleware: Vec::new(), + middleware, }); } // Fall back to `app.use(handler)` middleware registration. No @@ -192,6 +193,40 @@ mod tests { assert_eq!(binding.middleware[0].name, "koa.use"); } + #[test] + fn records_chained_middleware_and_global_app_use() { + 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\ + app.use(helmet());\n\ + app.use(logger);\n\ + async function authz(ctx, next) { await next(); }\n\ + async function validate(ctx, next) { await next(); }\n\ + async function handler(ctx) { ctx.body = 'ok'; }\n\ + router.post('/save', authz, validate, handler);\n"; + let tree = parse_js(src); + let binding = JsKoaAdapter + .detect(&summary("handler"), tree.root_node(), src) + .expect("binding"); + let names: Vec<_> = binding.middleware.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["helmet", "logger", "authz", "validate"]); + } + + #[test] + fn middleware_empty_when_route_has_no_chain() { + let src: &[u8] = b"const Koa = require('koa');\n\ + const Router = require('@koa/router');\n\ + const router = new Router();\n\ + async function handler(ctx) { ctx.body = 'ok'; }\n\ + router.get('/x', handler);\n"; + let tree = parse_js(src); + let binding = JsKoaAdapter + .detect(&summary("handler"), tree.root_node(), src) + .expect("binding"); + assert!(binding.middleware.is_empty()); + } + #[test] fn skips_when_koa_not_imported() { let src: &[u8] = b"const express = require('express');\n\ diff --git a/src/dynamic/framework/adapters/js_nest.rs b/src/dynamic/framework/adapters/js_nest.rs index 8ac608d6..93bc6699 100644 --- a/src/dynamic/framework/adapters/js_nest.rs +++ b/src/dynamic/framework/adapters/js_nest.rs @@ -19,7 +19,8 @@ //! tree-sitter parser is picked from `summary.lang`. use crate::dynamic::framework::{ - FrameworkAdapter, FrameworkBinding, HttpMethod, ParamBinding, ParamSource, RouteShape, + FrameworkAdapter, FrameworkBinding, HttpMethod, MiddlewareShape, ParamBinding, ParamSource, + RouteShape, }; use crate::evidence::EntryKind; use crate::summary::FuncSummary; @@ -28,7 +29,7 @@ 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, + last_segment, source_imports_nest, strip_quotes, }; pub struct JsNestAdapter; @@ -94,6 +95,7 @@ fn detect_nest( .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); + let middleware = collect_nest_middleware(class_node, method_node, file_bytes); Some(FrameworkBinding { adapter: adapter_name.to_owned(), kind: EntryKind::HttpRoute, @@ -103,7 +105,7 @@ fn detect_nest( }), request_params, response_writer: None, - middleware: Vec::new(), + middleware, }) } @@ -313,6 +315,115 @@ fn decorator_first_string_arg(decorator: Node<'_>, bytes: &[u8]) -> Option, + method_node: Node<'_>, + bytes: &[u8], +) -> Vec { + let mut out: Vec = Vec::new(); + for name in collect_use_decorators_for_node(class_node, bytes) { + out.push(MiddlewareShape { name }); + } + for name in collect_use_decorators_for_node(method_node, bytes) { + out.push(MiddlewareShape { name }); + } + out +} + +/// Walk `node`'s own `decorator` field children plus its preceding +/// `decorator` siblings, pulling argument names from any `@UseGuards` +/// / `@UseInterceptors` / `@UseFilters` / `@UsePipes` decorator +/// encountered. The two-source scan mirrors +/// [`class_has_controller`] / [`method_verb_and_path`] so the helper +/// behaves consistently across tree-sitter grammar variants that +/// either nest decorators inside the class/method node or hoist them +/// to preceding siblings. +fn collect_use_decorators_for_node(node: Node<'_>, bytes: &[u8]) -> Vec { + const USE_DECORATORS: &[&str] = &["UseGuards", "UseInterceptors", "UseFilters", "UsePipes"]; + let mut field_form: Vec = Vec::new(); + let mut cur = node.walk(); + for d in node.children_by_field_name("decorator", &mut cur) { + for &use_name in USE_DECORATORS { + if decorator_text_is(d, bytes, use_name) { + collect_decorator_arg_names(d, bytes, &mut field_form); + } + } + } + let mut sibling_form_groups: Vec> = Vec::new(); + let mut prev = node.prev_named_sibling(); + while let Some(sib) = prev { + if sib.kind() == "decorator" { + for &use_name in USE_DECORATORS { + if decorator_text_is(sib, bytes, use_name) { + let mut group: Vec = Vec::new(); + collect_decorator_arg_names(sib, bytes, &mut group); + sibling_form_groups.push(group); + } + } + prev = sib.prev_named_sibling(); + continue; + } + break; + } + let mut sibling_form: Vec = Vec::new(); + for group in sibling_form_groups.into_iter().rev() { + sibling_form.extend(group); + } + sibling_form.extend(field_form); + sibling_form +} + +/// Append each positional argument's display name from a decorator's +/// underlying `call_expression`. Identifiers contribute themselves; +/// member expressions contribute the last `.`-segment; call +/// expressions contribute the called function's last segment. Other +/// argument kinds (string literals, object literals) are skipped. +fn collect_decorator_arg_names(decorator: Node<'_>, bytes: &[u8], out: &mut Vec) { + let mut cur = decorator.walk(); + for c in decorator.children(&mut cur) { + if c.kind() != "call_expression" { + continue; + } + let Some(args) = c.child_by_field_name("arguments") else { + continue; + }; + let mut ac = args.walk(); + for a in args.named_children(&mut ac) { + match a.kind() { + "identifier" => { + if let Ok(text) = a.utf8_text(bytes) { + out.push(text.to_owned()); + } + } + "member_expression" => { + if let Ok(text) = a.utf8_text(bytes) { + out.push(last_segment(text).to_owned()); + } + } + "call_expression" => { + if let Some(fn_node) = a.child_by_field_name("function") + && let Ok(text) = fn_node.utf8_text(bytes) + { + out.push(last_segment(text).to_owned()); + } + } + _ => {} + } + } + } +} + /// Refine the per-formal binding shape using Nest's parameter /// decorators (`@Param('id')`, `@Query('q')`, `@Body()`, `@Headers()`, /// `@Req()` / `@Res()`). A `@Body()` formal becomes @@ -549,6 +660,61 @@ mod tests { } } + #[test] + fn records_method_use_guards_decorator() { + let src: &[u8] = b"import { Controller, Get, UseGuards } from '@nestjs/common';\n\ + import { AuthGuard } from './auth.guard';\n\ + @Controller('users')\n\ + export class UsersController {\n\ + @Get(':id')\n\ + @UseGuards(AuthGuard)\n\ + getUser(id: string) { return id; }\n\ + }\n"; + let tree = parse_ts(src); + let binding = TsNestAdapter + .detect(&summary("getUser", "typescript"), tree.root_node(), src) + .expect("binding"); + let names: Vec<_> = binding.middleware.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["AuthGuard"]); + } + + #[test] + fn records_class_and_method_use_decorators_in_order() { + let src: &[u8] = b"import { Controller, Post, UseGuards, UseInterceptors } from '@nestjs/common';\n\ + import { AuthGuard } from './auth.guard';\n\ + import { LoggingInterceptor } from './logging.interceptor';\n\ + import { RoleGuard } from './role.guard';\n\ + @Controller('admin')\n\ + @UseGuards(AuthGuard)\n\ + @UseInterceptors(LoggingInterceptor)\n\ + export class AdminController {\n\ + @Post('drop')\n\ + @UseGuards(RoleGuard)\n\ + drop(payload: string) { return payload; }\n\ + }\n"; + let tree = parse_ts(src); + let binding = TsNestAdapter + .detect(&summary("drop", "typescript"), tree.root_node(), src) + .expect("binding"); + let names: Vec<_> = binding.middleware.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["AuthGuard", "LoggingInterceptor", "RoleGuard"]); + } + + #[test] + fn middleware_empty_when_no_use_decorators() { + let src: &[u8] = b"import { Controller, Get } from '@nestjs/common';\n\ + @Controller('open')\n\ + export class OpenController {\n\ + @Get('list')\n\ + list() { return []; }\n\ + }\n"; + let tree = parse_ts(src); + let binding = TsNestAdapter + .detect(&summary("list", "typescript"), tree.root_node(), src) + .expect("binding"); + assert!(binding.middleware.is_empty()); + } + #[test] fn skips_when_not_a_nest_controller() { let src: &[u8] = b"import { Injectable } from '@nestjs/common';\n\ diff --git a/src/dynamic/framework/adapters/js_routes.rs b/src/dynamic/framework/adapters/js_routes.rs index 7e505b2e..6d5e1752 100644 --- a/src/dynamic/framework/adapters/js_routes.rs +++ b/src/dynamic/framework/adapters/js_routes.rs @@ -550,6 +550,10 @@ fn walk_for_middleware<'a>( for name in collect_chain_middleware_names(args, bytes, target) { route_chain.push(MiddlewareShape { name }); } + } else if prop_text == "route" { + for name in collect_options_middleware_names(args, bytes, target) { + route_chain.push(MiddlewareShape { name }); + } } } let mut cur = node.walk(); @@ -635,6 +639,93 @@ fn collect_use_arg_names(args: Node<'_>, bytes: &[u8]) -> Vec { out } +/// Collect middleware names from a Fastify options-object call +/// `fastify.route({ method, url, onRequest, preHandler, handler })`. +/// Inspects the pre-handler hook keys (`onRequest`, `preParsing`, +/// `preValidation`, `preHandler`) — each value may be a function +/// reference (identifier or `member_expression`), a factory call, or +/// an array literal of any of those. Returns the captured names in +/// source order across the four hook keys. Only fires when the +/// object's `handler:` property references `target`; otherwise an +/// unrelated route's hooks would leak into the binding. +fn collect_options_middleware_names(args: Node<'_>, bytes: &[u8], target: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = args.walk(); + for c in args.named_children(&mut cur) { + if c.kind() != "object" { + continue; + } + let mut handler_matches = false; + let mut hook_names: Vec = Vec::new(); + let mut oc = c.walk(); + for pair in c.named_children(&mut oc) { + if pair.kind() != "pair" { + continue; + } + let Some(key_raw) = 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_raw.trim_matches(['\'', '"', '`']); + match key { + "handler" => { + if view_arg_references(value, bytes, target) { + handler_matches = true; + } + } + "onRequest" | "preParsing" | "preValidation" | "preHandler" => { + collect_hook_value_names(value, bytes, &mut hook_names); + } + _ => {} + } + } + if handler_matches { + out.extend(hook_names); + } + } + out +} + +/// Recursively collect identifier / member-expression / call / array +/// references from a Fastify hook value into `out`. Used by +/// [`collect_options_middleware_names`] — supports the three documented +/// hook value shapes: a single function reference +/// (`preHandler: authz`), a factory call (`preHandler: authz()`), or +/// an array of references (`preHandler: [authz, validate]`). +fn collect_hook_value_names(value: Node<'_>, bytes: &[u8], out: &mut Vec) { + match value.kind() { + "identifier" => { + if let Ok(text) = value.utf8_text(bytes) { + out.push(text.to_owned()); + } + } + "member_expression" => { + if let Ok(text) = value.utf8_text(bytes) { + out.push(last_segment(text).to_owned()); + } + } + "call_expression" => { + if let Some(fn_node) = value.child_by_field_name("function") + && let Ok(text) = fn_node.utf8_text(bytes) + { + out.push(last_segment(text).to_owned()); + } + } + "array" => { + let mut cur = value.walk(); + for c in value.named_children(&mut cur) { + collect_hook_value_names(c, bytes, out); + } + } + _ => {} + } +} + /// Parse a Fastify options-object call `fastify.route({ method, url, /// handler })` returning the bound `(method, url)` when the /// `handler:` property references `target`. @@ -820,6 +911,36 @@ mod tests { assert_eq!(names, vec!["csrf", "auth"]); } + #[test] + fn extract_middleware_picks_up_fastify_options_pre_handler() { + let src: &[u8] = b"fastify.route({\n\ + method: 'POST',\n\ + url: '/items',\n\ + onRequest: tokenAuth,\n\ + preHandler: [authz, validate],\n\ + handler: handler,\n\ + });\n"; + let tree = parse_js(src); + let recv = |n: &str| n == "fastify"; + let mw = extract_route_middleware(tree.root_node(), src, "handler", &recv); + let names: Vec<_> = mw.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["tokenAuth", "authz", "validate"]); + } + + #[test] + fn extract_middleware_ignores_fastify_options_with_different_handler() { + let src: &[u8] = b"fastify.route({\n\ + method: 'POST',\n\ + url: '/items',\n\ + preHandler: authz,\n\ + handler: other,\n\ + });\n"; + let tree = parse_js(src); + let recv = |n: &str| n == "fastify"; + let mw = extract_route_middleware(tree.root_node(), src, "handler", &recv); + assert!(mw.is_empty()); + } + #[test] fn find_route_registration_matches_fastify_options_object() { let src: &[u8] =