[pitboss/grind] deferred session-0009 (20260521T201327Z-3848)

This commit is contained in:
pitboss 2026-05-21 17:34:13 -05:00
parent ad15761042
commit 2548a4a802
4 changed files with 379 additions and 9 deletions

View file

@ -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\

View file

@ -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\

View file

@ -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<Strin
None
}
/// Collect Nest middleware names from `@UseGuards(...)` /
/// `@UseInterceptors(...)` / `@UseFilters(...)` / `@UsePipes(...)`
/// decorators on the class **and** on the method. Class-level
/// decorators fire before the method-level ones at runtime, so they
/// are recorded first in the returned vector. Each decorator may
/// carry one or more positional arguments (e.g.
/// `@UseGuards(AuthGuard, RoleGuard)`); the recorded names are the
/// last segment of each identifier / member-expression argument so
/// `mod.AuthGuard` collapses to `AuthGuard`. Call-expression
/// arguments (`@UseGuards(authGuard())`) record the called function's
/// last segment.
fn collect_nest_middleware(
class_node: Node<'_>,
method_node: Node<'_>,
bytes: &[u8],
) -> Vec<MiddlewareShape> {
let mut out: Vec<MiddlewareShape> = 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<String> {
const USE_DECORATORS: &[&str] = &["UseGuards", "UseInterceptors", "UseFilters", "UsePipes"];
let mut field_form: Vec<String> = 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<String>> = 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<String> = 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<String> = 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<String>) {
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\

View file

@ -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<String> {
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<String> {
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<String> = 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<String>) {
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] =