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

This commit is contained in:
pitboss 2026-05-21 17:18:25 -05:00
parent d47aec54bf
commit cf00cff5a6
6 changed files with 469 additions and 7 deletions

View file

@ -19,8 +19,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_express,
bind_path_params, extract_route_middleware, find_function_params, find_route_registration,
function_formal_names, source_imports_express,
};
pub struct JsExpressAdapter;
@ -61,13 +61,14 @@ impl FrameworkAdapter for JsExpressAdapter {
.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,
})
}
}
@ -145,7 +146,40 @@ mod tests {
let binding = JsExpressAdapter
.detect(&summary("handler"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.route.unwrap().method, HttpMethod::DELETE);
assert_eq!(binding.route.as_ref().unwrap().method, HttpMethod::DELETE);
let names: Vec<_> = binding.middleware.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, vec!["authz"]);
}
#[test]
fn records_chained_middleware_and_global_app_use() {
let src: &[u8] = b"const express = require('express');\n\
const app = express();\n\
app.use(helmet());\n\
app.use(logger);\n\
function authz(req, res, next) { next(); }\n\
function validate(req, res, next) { next(); }\n\
function handler(req, res) { res.send('ok'); }\n\
app.post('/save', authz, validate, handler);\n";
let tree = parse_js(src);
let binding = JsExpressAdapter
.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 express = require('express');\n\
const app = express();\n\
function handler(req, res) { res.send('ok'); }\n\
app.get('/x', handler);\n";
let tree = parse_js(src);
let binding = JsExpressAdapter
.detect(&summary("handler"), tree.root_node(), src)
.expect("binding");
assert!(binding.middleware.is_empty());
}
#[test]

View file

@ -9,7 +9,7 @@
//! on whether a placeholder of the same name appears in the path
//! template.
use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource};
use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource};
use tree_sitter::Node;
/// True when `bytes` carries any of the well-known Express import
@ -486,6 +486,155 @@ pub fn first_string_arg(args: Node<'_>, bytes: &[u8]) -> Option<String> {
None
}
/// Walk `root` collecting middleware names attached to a route
/// registration. Two sites are inspected:
///
/// 1. The positional `<receiver>.<verb>(<path>, mw1, mw2, …, handler)`
/// chain on the matching route call — every identifier-shaped
/// positional argument between the path string and `target`
/// becomes a [`MiddlewareShape`].
/// 2. Every preceding `<receiver>.use(<mw>)` call at the top level —
/// `<mw>` may be a bare identifier (`app.use(authMw)`) or a
/// call expression (`app.use(authMw())`), and the recorded name
/// is the identifier / called-function last segment.
///
/// Names are recorded in source order: global `app.use(...)` first
/// (because they fire before the route), then per-route chained
/// middleware. Duplicate names are kept — repeated registrations are
/// real, e.g. `app.use(logger); app.use(logger);`.
pub fn extract_route_middleware(
root: Node<'_>,
bytes: &[u8],
target: &str,
receiver_accepts: &dyn Fn(&str) -> bool,
) -> Vec<MiddlewareShape> {
let mut global: Vec<MiddlewareShape> = Vec::new();
let mut route_chain: Vec<MiddlewareShape> = Vec::new();
walk_for_middleware(
root,
bytes,
target,
receiver_accepts,
&mut global,
&mut route_chain,
);
global.extend(route_chain);
global
}
fn walk_for_middleware<'a>(
node: Node<'a>,
bytes: &[u8],
target: &str,
receiver_accepts: &dyn Fn(&str) -> bool,
global: &mut Vec<MiddlewareShape>,
route_chain: &mut Vec<MiddlewareShape>,
) {
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()
&& receiver_accepts(last_segment(object_text))
&& let Some(args) = node.child_by_field_name("arguments")
{
if prop_text == "use" {
for name in collect_use_arg_names(args, bytes) {
global.push(MiddlewareShape { name });
}
} else if http_verb_from_method(prop_text).is_some()
&& call_args_reference_target(args, bytes, target)
{
for name in collect_chain_middleware_names(args, bytes, target) {
route_chain.push(MiddlewareShape { name });
}
}
}
let mut cur = node.walk();
for child in node.children(&mut cur) {
walk_for_middleware(child, bytes, target, receiver_accepts, global, route_chain);
}
}
/// Pull middleware names from a positional `(<path>, mw1, mw2, …,
/// handler)` arguments node. Skips the leading string-literal path,
/// stops at the named handler reference, and ignores object-literal
/// option arguments (Fastify's `{ schema, preHandler, … }` shape is
/// handled separately by [`collect_options_middleware_names`]).
fn collect_chain_middleware_names(args: Node<'_>, bytes: &[u8], target: &str) -> Vec<String> {
let mut out = Vec::new();
let mut seen_path_literal = false;
let mut cur = args.walk();
for c in args.named_children(&mut cur) {
match c.kind() {
"string" | "template_string" if !seen_path_literal => {
seen_path_literal = true;
}
"identifier" => {
if let Ok(text) = c.utf8_text(bytes) {
if text == target {
break;
}
out.push(text.to_owned());
}
}
"member_expression" => {
if let Ok(text) = c.utf8_text(bytes) {
let last = last_segment(text);
if last == target {
break;
}
out.push(last.to_owned());
}
}
"call_expression" => {
// Inline middleware factory call like `auth({ role: 'admin' })`.
if let Some(fn_node) = c.child_by_field_name("function")
&& let Ok(text) = fn_node.utf8_text(bytes)
{
out.push(last_segment(text).to_owned());
}
}
_ => {}
}
}
out
}
/// Pull middleware names from a `<receiver>.use(<mw>, [<mw>, …])` call.
/// Each positional argument that resolves to an identifier or a call
/// expression contributes one entry; string-named middleware modules
/// (`app.use('/admin', adminRouter)`) skip the path string.
fn collect_use_arg_names(args: Node<'_>, bytes: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut cur = args.walk();
for c in args.named_children(&mut cur) {
match c.kind() {
"identifier" => {
if let Ok(text) = c.utf8_text(bytes) {
out.push(text.to_owned());
}
}
"member_expression" => {
if let Ok(text) = c.utf8_text(bytes) {
out.push(last_segment(text).to_owned());
}
}
"call_expression" => {
if let Some(fn_node) = c.child_by_field_name("function")
&& let Ok(text) = fn_node.utf8_text(bytes)
{
out.push(last_segment(text).to_owned());
}
}
_ => {}
}
}
out
}
/// Parse a Fastify options-object call `fastify.route({ method, url,
/// handler })` returning the bound `(method, url)` when the
/// `handler:` property references `target`.
@ -629,6 +778,48 @@ mod tests {
assert_eq!(path, "/save");
}
#[test]
fn extract_middleware_picks_up_chain_args() {
let src: &[u8] = b"app.post('/save', authz, validate, handler);\n";
let tree = parse_js(src);
let recv = |n: &str| n == "app";
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!["authz", "validate"]);
}
#[test]
fn extract_middleware_records_app_use_in_order() {
let src: &[u8] = b"app.use(helmet());\napp.use(logger);\napp.get('/x', handler);\n";
let tree = parse_js(src);
let recv = |n: &str| n == "app";
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!["helmet", "logger"]);
}
#[test]
fn extract_middleware_returns_empty_on_no_chain() {
let src: &[u8] = b"app.get('/x', handler);\n";
let tree = parse_js(src);
let recv = |n: &str| n == "app";
let mw = extract_route_middleware(tree.root_node(), src, "handler", &recv);
assert!(mw.is_empty());
}
#[test]
fn extract_middleware_skips_member_expression_path_alias() {
let src: &[u8] =
b"app.post('/save', mw.csrf, mw.auth, controller.save);\n";
let tree = parse_js(src);
let recv = |n: &str| n == "app";
let mw = extract_route_middleware(tree.root_node(), src, "save", &recv);
let names: Vec<_> = mw.iter().map(|m| m.name.as_str()).collect();
// `controller.save` is the handler; everything before is middleware.
// We record the last segment of each member expression.
assert_eq!(names, vec!["csrf", "auth"]);
}
#[test]
fn find_route_registration_matches_fastify_options_object() {
let src: &[u8] =

View file

@ -0,0 +1,233 @@
//! Flyway migration adapter (Java).
//!
//! Fires when the surrounding source declares a Java class extending
//! `BaseJavaMigration` or implementing `JavaMigration` from the
//! `org.flywaydb.core.api.migration` package, and the function under
//! analysis is the canonical `migrate(Context)` entry point or runs
//! JDBC DDL through the context-supplied connection.
//!
//! Notably does NOT fire just because a helper method is named
//! `migrate` in a file that has no Flyway import marker. The
//! source-shape needle plus the entry-name / DDL-callee gate together
//! mirror the Phase 21 binding-stealing audit applied to
//! `migration_rails` and `migration_django`.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct MigrationFlywayAdapter;
const ADAPTER_NAME: &str = "migration-flyway";
fn callee_is_flyway_ddl(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"execute"
| "executeUpdate"
| "executeQuery"
| "executeLargeUpdate"
| "prepareStatement"
| "createStatement"
| "addBatch"
| "executeBatch"
)
}
fn source_has_flyway_shape(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"org.flywaydb.core.api.migration.BaseJavaMigration",
b"org.flywaydb.core.api.migration.JavaMigration",
b"org.flywaydb.core.api.migration.Context",
b"extends BaseJavaMigration",
b"implements JavaMigration",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
fn name_is_migration_entry(name: &str) -> bool {
matches!(name, "migrate")
}
/// Pull the version out of the Flyway filename convention. Real
/// Flyway parses the version from the class name (`V1_2_3__Add_users`
/// → `1.2.3`) using the same rule documented at
/// <https://documentation.red-gate.com/fd/migrations-184127470.html>.
/// We approximate by scanning the file bytes for a `class V<ver>__`
/// declaration; if missing, return `None` so the verifier can fall
/// back to filename-based version derivation later in the pipeline.
fn extract_version(file_bytes: &[u8]) -> Option<String> {
let text = std::str::from_utf8(file_bytes).unwrap_or("");
for marker in ["class V", "public class V"] {
if let Some(idx) = text.find(marker) {
let after = &text[idx + marker.len()..];
if let Some(sep) = after.find("__") {
let raw = &after[..sep];
let normalised: String = raw
.chars()
.map(|c| if c == '_' { '.' } else { c })
.collect();
if !normalised.is_empty()
&& normalised
.chars()
.all(|c| c.is_ascii_digit() || c == '.')
{
return Some(normalised);
}
}
}
}
None
}
impl FrameworkAdapter for MigrationFlywayAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Java
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let has_shape = source_has_flyway_shape(file_bytes);
let name_matches = name_is_migration_entry(&summary.name);
let body_runs_ddl = super::any_callee_matches(summary, callee_is_flyway_ddl);
let binds = has_shape && (name_matches || body_runs_ddl);
if !binds {
return None;
}
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Migration {
version: extract_version(file_bytes),
},
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_java(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_base_java_migration_subclass() {
let src: &[u8] = b"import org.flywaydb.core.api.migration.BaseJavaMigration;\n\
import org.flywaydb.core.api.migration.Context;\n\
public class V1_2_3__Add_users extends BaseJavaMigration {\n\
public void migrate(Context context) throws Exception { }\n\
}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "migrate".into(),
..Default::default()
};
let binding = MigrationFlywayAdapter
.detect(&summary, tree.root_node(), src)
.expect("flyway migration binds");
assert_eq!(binding.adapter, "migration-flyway");
if let EntryKind::Migration { version } = binding.kind {
assert_eq!(version.as_deref(), Some("1.2.3"));
} else {
panic!("expected Migration entry kind");
}
}
#[test]
fn fires_when_implementing_java_migration_interface() {
let src: &[u8] = b"import org.flywaydb.core.api.migration.JavaMigration;\n\
public class Boot implements JavaMigration {\n\
public void migrate(Object ctx) { }\n\
}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "migrate".into(),
..Default::default()
};
assert!(
MigrationFlywayAdapter
.detect(&summary, tree.root_node(), src)
.is_some(),
"interface-based Flyway migration must bind",
);
}
#[test]
fn skips_helper_named_migrate_without_flyway_import() {
let src: &[u8] = b"public class Helper {\n\
public void migrate(Object ctx) { }\n\
}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "migrate".into(),
..Default::default()
};
assert!(
MigrationFlywayAdapter
.detect(&summary, tree.root_node(), src)
.is_none(),
"helper named `migrate` without Flyway import must not bind",
);
}
#[test]
fn skips_unrelated_method_in_flyway_file() {
let src: &[u8] = b"import org.flywaydb.core.api.migration.BaseJavaMigration;\n\
public class V1__Init extends BaseJavaMigration {\n\
public void helper() { }\n\
public void migrate(Object ctx) { }\n\
}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "helper".into(),
..Default::default()
};
assert!(
MigrationFlywayAdapter
.detect(&summary, tree.root_node(), src)
.is_none(),
"helper method that does not run DDL must not bind even inside a Flyway file",
);
}
#[test]
fn extracts_dotted_version_from_filename_class() {
let src: &[u8] = b"import org.flywaydb.core.api.migration.BaseJavaMigration;\n\
public class V2_0__Seed extends BaseJavaMigration {\n\
public void migrate(Object ctx) { }\n\
}\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "migrate".into(),
..Default::default()
};
let binding = MigrationFlywayAdapter
.detect(&summary, tree.root_node(), src)
.expect("binds");
if let EntryKind::Migration { version } = binding.kind {
assert_eq!(version.as_deref(), Some("2.0"));
} else {
panic!("expected Migration entry kind");
}
}
}

View file

@ -53,6 +53,7 @@ pub mod middleware_rails;
pub mod middleware_spring;
pub mod migration_django;
pub mod migration_flask;
pub mod migration_flyway;
pub mod migration_laravel;
pub mod migration_prisma;
pub mod migration_rails;
@ -156,6 +157,7 @@ pub use middleware_rails::MiddlewareRailsAdapter;
pub use middleware_spring::MiddlewareSpringAdapter;
pub use migration_django::MigrationDjangoAdapter;
pub use migration_flask::MigrationFlaskAdapter;
pub use migration_flyway::MigrationFlywayAdapter;
pub use migration_laravel::MigrationLaravelAdapter;
pub use migration_prisma::MigrationPrismaAdapter;
pub use migration_rails::MigrationRailsAdapter;

View file

@ -220,6 +220,7 @@ mod tests {
// / `Middleware` / `Migration` — distributed across the
// language slices. Per-lang deltas vs the Phase 20 baseline:
// Java: +2 (ScheduledQuartz, MiddlewareSpring) 14 → 16
// +1 follow-up (MigrationFlyway) 16 → 17
// Php: +2 (MiddlewareLaravel, MigrationLaravel) 10 → 12
// Python: +7 (GraphqlGraphene, MiddlewareDjango,
// MigrationDjango, MigrationFlask,
@ -237,8 +238,8 @@ mod tests {
let java_registered = registry::adapters_for(Lang::Java);
assert_eq!(
java_registered.len(),
16,
"Java must have Phase 20 baseline (14) + M.3 Quartz/Spring-middleware (2)",
17,
"Java must have Phase 20 baseline (14) + M.3 Quartz/Spring-middleware (2) + Flyway (1)",
);
for adapter in java_registered {
assert_eq!(adapter.lang(), Lang::Java);

View file

@ -66,6 +66,7 @@ static JAVA: &[&dyn FrameworkAdapter] = &[
&super::adapters::KafkaJavaAdapter,
&super::adapters::LdapSpringAdapter,
&super::adapters::MiddlewareSpringAdapter,
&super::adapters::MigrationFlywayAdapter,
&super::adapters::RabbitJavaAdapter,
&super::adapters::RedirectJavaAdapter,
&super::adapters::ScheduledQuartzAdapter,