diff --git a/src/dynamic/framework/adapters/graphql_apollo.rs b/src/dynamic/framework/adapters/graphql_apollo.rs new file mode 100644 index 00000000..24f3e3f5 --- /dev/null +++ b/src/dynamic/framework/adapters/graphql_apollo.rs @@ -0,0 +1,112 @@ +//! Phase 21 (Track M.3) — Apollo GraphQL resolver adapter (JS). +//! +//! Fires when the surrounding source imports `@apollo/server` / the +//! legacy `apollo-server` / `apollo-server-express` package, or the +//! function body sits inside a `Query` / `Mutation` resolver map. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct GraphqlApolloAdapter; + +const ADAPTER_NAME: &str = "graphql-apollo"; + +fn callee_is_apollo(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "ApolloServer" | "startStandaloneServer" | "gql" | "applyMiddleware" | "expressMiddleware" + ) +} + +fn source_imports_apollo(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"@apollo/server", + b"apollo-server", + b"require('apollo-server')", + b"require(\"apollo-server\")", + b"from 'apollo-server", + b"from \"apollo-server", + b"new ApolloServer", + b"const resolvers", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_resolver(summary: &FuncSummary) -> (String, String) { + // Best-effort: split a fully-qualified name like `Query.user` into + // `("Query", "user")`. Falls back to ("Query", name) so the + // binding always carries some type_name + field. + if let Some((parent, field)) = summary.name.rsplit_once('.') { + return (parent.to_owned(), field.to_owned()); + } + ("Query".to_owned(), summary.name.clone()) +} + +impl FrameworkAdapter for GraphqlApolloAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::JavaScript + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_apollo); + let matches_source = source_imports_apollo(file_bytes); + if matches_call || matches_source { + let (type_name, field) = extract_resolver(summary); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::GraphQLResolver { type_name, field }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + 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 fires_on_apollo_resolver() { + let src: &[u8] = b"const { ApolloServer } = require('@apollo/server');\n\ + const resolvers = { Query: { user: (_, { id }) => id } };\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "user".into(), + ..Default::default() + }; + let binding = GraphqlApolloAdapter + .detect(&summary, tree.root_node(), src) + .expect("apollo binds"); + assert_eq!(binding.adapter, "graphql-apollo"); + if let EntryKind::GraphQLResolver { type_name, field } = binding.kind { + assert_eq!(type_name, "Query"); + assert_eq!(field, "user"); + } + } +} diff --git a/src/dynamic/framework/adapters/graphql_gqlgen.rs b/src/dynamic/framework/adapters/graphql_gqlgen.rs new file mode 100644 index 00000000..3cd75f98 --- /dev/null +++ b/src/dynamic/framework/adapters/graphql_gqlgen.rs @@ -0,0 +1,103 @@ +//! Phase 21 (Track M.3) — gqlgen (Go) GraphQL resolver adapter. +//! +//! Fires when the surrounding source imports the gqlgen runtime or +//! declares a resolver method on a `*queryResolver` / `*mutationResolver` +//! receiver — the canonical shape gqlgen generates. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct GraphqlGqlgenAdapter; + +const ADAPTER_NAME: &str = "graphql-gqlgen"; + +fn callee_is_gqlgen(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "NewExecutableSchema" | "handler" | "Playground" | "GraphQL" | "Recover" + ) +} + +fn source_imports_gqlgen(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"github.com/99designs/gqlgen", + b"gqlgen/graphql", + b"queryResolver", + b"mutationResolver", + b"Resolver) Query(", + b"Resolver) Mutation(", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_resolver(summary: &FuncSummary) -> (String, String) { + ("Query".to_owned(), summary.name.clone()) +} + +impl FrameworkAdapter for GraphqlGqlgenAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Go + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_gqlgen); + let matches_source = source_imports_gqlgen(file_bytes); + if matches_call || matches_source { + let (type_name, field) = extract_resolver(summary); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::GraphQLResolver { type_name, field }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_go(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_gqlgen_query_resolver() { + let src: &[u8] = b"package graph\n\ + import \"github.com/99designs/gqlgen/graphql\"\n\ + type queryResolver struct{}\n\ + func (r *queryResolver) User(ctx context.Context, id string) (string, error) { return id, nil }\n"; + let tree = parse_go(src); + let summary = FuncSummary { + name: "User".into(), + ..Default::default() + }; + let binding = GraphqlGqlgenAdapter + .detect(&summary, tree.root_node(), src) + .expect("gqlgen binds"); + assert_eq!(binding.adapter, "graphql-gqlgen"); + assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. })); + } +} diff --git a/src/dynamic/framework/adapters/graphql_graphene.rs b/src/dynamic/framework/adapters/graphql_graphene.rs new file mode 100644 index 00000000..93216770 --- /dev/null +++ b/src/dynamic/framework/adapters/graphql_graphene.rs @@ -0,0 +1,107 @@ +//! Phase 21 (Track M.3) — Graphene (Python) GraphQL resolver adapter. +//! +//! Fires when the surrounding source imports `graphene` and the +//! function body sits inside a `graphene.ObjectType` with a +//! `resolve_` definition. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct GraphqlGrapheneAdapter; + +const ADAPTER_NAME: &str = "graphql-graphene"; + +fn callee_is_graphene(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "Schema" | "ObjectType" | "Field" | "String" | "Int" | "List" + ) +} + +fn source_imports_graphene(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"import graphene", + b"from graphene", + b"graphene.ObjectType", + b"graphene.Schema", + b"graphene.Field", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_resolver(summary: &FuncSummary) -> (String, String) { + // `resolve_user` → ("Query", "user"). Best-effort. + if let Some(field) = summary.name.strip_prefix("resolve_") { + return ("Query".to_owned(), field.to_owned()); + } + ("Query".to_owned(), summary.name.clone()) +} + +impl FrameworkAdapter for GraphqlGrapheneAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Python + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_graphene); + let matches_source = source_imports_graphene(file_bytes); + if matches_call || matches_source { + let (type_name, field) = extract_resolver(summary); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::GraphQLResolver { type_name, field }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_python(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_graphene_resolver() { + let src: &[u8] = b"import graphene\n\ + class Query(graphene.ObjectType):\n user = graphene.String()\n def resolve_user(self, info, id):\n return id\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "resolve_user".into(), + ..Default::default() + }; + let binding = GraphqlGrapheneAdapter + .detect(&summary, tree.root_node(), src) + .expect("graphene binds"); + assert_eq!(binding.adapter, "graphql-graphene"); + if let EntryKind::GraphQLResolver { type_name, field } = binding.kind { + assert_eq!(type_name, "Query"); + assert_eq!(field, "user"); + } + } +} diff --git a/src/dynamic/framework/adapters/graphql_juniper.rs b/src/dynamic/framework/adapters/graphql_juniper.rs new file mode 100644 index 00000000..2b816bcb --- /dev/null +++ b/src/dynamic/framework/adapters/graphql_juniper.rs @@ -0,0 +1,101 @@ +//! Phase 21 (Track M.3) — Juniper (Rust) GraphQL resolver adapter. +//! +//! Fires when the surrounding source imports the `juniper` crate and +//! the function body sits inside a `#[graphql_object]` impl block. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct GraphqlJuniperAdapter; + +const ADAPTER_NAME: &str = "graphql-juniper"; + +fn callee_is_juniper(name: &str) -> bool { + let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "RootNode" | "EmptyMutation" | "EmptySubscription" | "execute" | "execute_sync" + ) +} + +fn source_imports_juniper(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"use juniper", + b"juniper::", + b"#[graphql_object", + b"#[derive(GraphQLObject)]", + b"juniper::EmptyMutation", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_resolver(summary: &FuncSummary) -> (String, String) { + ("Query".to_owned(), summary.name.clone()) +} + +impl FrameworkAdapter for GraphqlJuniperAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Rust + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_juniper); + let matches_source = source_imports_juniper(file_bytes); + if matches_call || matches_source { + let (type_name, field) = extract_resolver(summary); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::GraphQLResolver { type_name, field }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_rust(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_juniper_graphql_object() { + let src: &[u8] = b"use juniper::graphql_object;\n\ + pub struct Query;\n\ + #[graphql_object]\n\ + impl Query {\n fn user(&self, id: String) -> String { id }\n}\n"; + let tree = parse_rust(src); + let summary = FuncSummary { + name: "user".into(), + ..Default::default() + }; + let binding = GraphqlJuniperAdapter + .detect(&summary, tree.root_node(), src) + .expect("juniper binds"); + assert_eq!(binding.adapter, "graphql-juniper"); + assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. })); + } +} diff --git a/src/dynamic/framework/adapters/graphql_relay.rs b/src/dynamic/framework/adapters/graphql_relay.rs new file mode 100644 index 00000000..46983070 --- /dev/null +++ b/src/dynamic/framework/adapters/graphql_relay.rs @@ -0,0 +1,111 @@ +//! Phase 21 (Track M.3) — Relay GraphQL resolver adapter (JS). +//! +//! Relay is the Facebook GraphQL client + spec; on the server side +//! `graphql-relay` provides node-id / connection helpers wrapped around +//! the standard `graphql-js` resolver shape. Fires when the source +//! imports `graphql-relay` / declares a node-id resolver or a +//! `mutationWithClientMutationId` helper. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct GraphqlRelayAdapter; + +const ADAPTER_NAME: &str = "graphql-relay"; + +fn callee_is_relay(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "nodeDefinitions" + | "mutationWithClientMutationId" + | "connectionDefinitions" + | "globalIdField" + | "fromGlobalId" + ) +} + +fn source_imports_relay(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"graphql-relay", + b"require('graphql-relay')", + b"require(\"graphql-relay\")", + b"from 'graphql-relay'", + b"from \"graphql-relay\"", + b"nodeDefinitions", + b"mutationWithClientMutationId", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_resolver(summary: &FuncSummary) -> (String, String) { + if let Some((parent, field)) = summary.name.rsplit_once('.') { + return (parent.to_owned(), field.to_owned()); + } + ("Node".to_owned(), summary.name.clone()) +} + +impl FrameworkAdapter for GraphqlRelayAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::JavaScript + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_relay); + let matches_source = source_imports_relay(file_bytes); + if matches_call || matches_source { + let (type_name, field) = extract_resolver(summary); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::GraphQLResolver { type_name, field }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + 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 fires_on_relay_node_definitions() { + let src: &[u8] = b"const { nodeDefinitions, fromGlobalId } = require('graphql-relay');\n\ + function resolveUser(globalId) { return fromGlobalId(globalId); }\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "resolveUser".into(), + ..Default::default() + }; + let binding = GraphqlRelayAdapter + .detect(&summary, tree.root_node(), src) + .expect("relay binds"); + assert_eq!(binding.adapter, "graphql-relay"); + assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. })); + } +} diff --git a/src/dynamic/framework/adapters/middleware_django.rs b/src/dynamic/framework/adapters/middleware_django.rs new file mode 100644 index 00000000..c84f6fbd --- /dev/null +++ b/src/dynamic/framework/adapters/middleware_django.rs @@ -0,0 +1,102 @@ +//! Phase 21 (Track M.3) — Django middleware adapter (Python). +//! +//! Fires when the surrounding source imports Django middleware base +//! classes (`MiddlewareMixin`) or declares a callable middleware whose +//! body defines `__call__(self, request)` / `process_request`. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct MiddlewareDjangoAdapter; + +const ADAPTER_NAME: &str = "middleware-django"; + +fn callee_is_django_middleware(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "process_request" + | "process_response" + | "process_view" + | "process_exception" + | "__call__" + ) +} + +fn source_imports_django_middleware(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"django.utils.deprecation", + b"MiddlewareMixin", + b"def __call__(self, request", + b"def process_request", + b"django.middleware", + b"MIDDLEWARE = [", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for MiddlewareDjangoAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Python + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_django_middleware); + let matches_source = source_imports_django_middleware(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::Middleware { + name: summary.name.clone(), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_python(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_django_middleware() { + let src: &[u8] = b"from django.utils.deprecation import MiddlewareMixin\n\ + class AuditMiddleware(MiddlewareMixin):\n def process_request(self, request):\n pass\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "process_request".into(), + ..Default::default() + }; + let binding = MiddlewareDjangoAdapter + .detect(&summary, tree.root_node(), src) + .expect("django middleware binds"); + assert_eq!(binding.adapter, "middleware-django"); + assert!(matches!(binding.kind, EntryKind::Middleware { .. })); + } +} diff --git a/src/dynamic/framework/adapters/middleware_express.rs b/src/dynamic/framework/adapters/middleware_express.rs new file mode 100644 index 00000000..4787e005 --- /dev/null +++ b/src/dynamic/framework/adapters/middleware_express.rs @@ -0,0 +1,104 @@ +//! Phase 21 (Track M.3) — Express middleware adapter (JS). +//! +//! Fires when the surrounding source imports Express and declares a +//! middleware function — a `(req, res, next) => …` callable mounted +//! via `app.use(...)` / `router.use(...)`. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct MiddlewareExpressAdapter; + +const ADAPTER_NAME: &str = "middleware-express"; + +fn callee_is_express(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "use" | "next" | "json" | "urlencoded" | "static" + ) +} + +fn source_imports_express(file_bytes: &[u8]) -> bool { + // Phase 21 v1: require an explicit middleware-registration shape + // (`app.use(` / `router.use(`), not the bare `require('express')` + // import. Many non-middleware Express fixtures import the framework + // but never declare middleware; gating on the registration shape + // keeps the adapter focused on the function the brief targets. + const NEEDLES: &[&[u8]] = &[ + b"app.use(", + b"router.use(", + b"express.Router()", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for MiddlewareExpressAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::JavaScript + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_express); + let matches_source = source_imports_express(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::Middleware { + name: summary.name.clone(), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + 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 fires_on_express_middleware() { + let src: &[u8] = b"const express = require('express');\n\ + const app = express();\n\ + function audit(req, res, next) { next(); }\n\ + app.use(audit);\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "audit".into(), + ..Default::default() + }; + let binding = MiddlewareExpressAdapter + .detect(&summary, tree.root_node(), src) + .expect("express middleware binds"); + assert_eq!(binding.adapter, "middleware-express"); + if let EntryKind::Middleware { name } = binding.kind { + assert_eq!(name, "audit"); + } + } +} diff --git a/src/dynamic/framework/adapters/middleware_laravel.rs b/src/dynamic/framework/adapters/middleware_laravel.rs new file mode 100644 index 00000000..b2945c9d --- /dev/null +++ b/src/dynamic/framework/adapters/middleware_laravel.rs @@ -0,0 +1,94 @@ +//! Phase 21 (Track M.3) — Laravel middleware adapter (PHP). +//! +//! Fires when the surrounding source declares a class with a `handle` +//! method whose signature matches Laravel's middleware contract +//! (`$request, Closure $next`). + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct MiddlewareLaravelAdapter; + +const ADAPTER_NAME: &str = "middleware-laravel"; + +fn callee_is_laravel_middleware(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!(last, "handle" | "terminate" | "next" | "withMiddleware") +} + +fn source_imports_laravel_middleware(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"Illuminate\\Http\\Request", + b"Illuminate\\Foundation\\Http\\Middleware", + b"function handle($request, Closure $next", + b"function handle(Request $request, Closure $next", + b"app/Http/Middleware", + b"$middleware", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for MiddlewareLaravelAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Php + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_laravel_middleware); + let matches_source = source_imports_laravel_middleware(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::Middleware { + name: summary.name.clone(), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_php(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_laravel_handle() { + let src: &[u8] = b" bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "call" | "before_action" | "around_action" | "after_action" | "use" + ) +} + +fn source_imports_rails_middleware(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"def call(env)", + b"def call (env", + b"before_action ", + b"after_action ", + b"around_action ", + b"Rails.application.config.middleware", + b"Rack::Builder", + b"@app = app", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for MiddlewareRailsAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Ruby + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_rails_middleware); + let matches_source = source_imports_rails_middleware(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::Middleware { + name: summary.name.clone(), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_ruby(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_rack_middleware_call() { + let src: &[u8] = b"class AuditMiddleware\n def initialize(app); @app = app; end\n def call(env)\n @app.call(env)\n end\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "call".into(), + ..Default::default() + }; + let binding = MiddlewareRailsAdapter + .detect(&summary, tree.root_node(), src) + .expect("rack middleware binds"); + assert_eq!(binding.adapter, "middleware-rails"); + assert!(matches!(binding.kind, EntryKind::Middleware { .. })); + } +} diff --git a/src/dynamic/framework/adapters/middleware_spring.rs b/src/dynamic/framework/adapters/middleware_spring.rs new file mode 100644 index 00000000..e87a500d --- /dev/null +++ b/src/dynamic/framework/adapters/middleware_spring.rs @@ -0,0 +1,98 @@ +//! Phase 21 (Track M.3) — Spring `HandlerInterceptor` middleware +//! adapter (Java). +//! +//! Fires when the surrounding source imports +//! `org.springframework.web.servlet.HandlerInterceptor` or `Filter` and +//! the function body is `preHandle` / `postHandle` / `doFilter`. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct MiddlewareSpringAdapter; + +const ADAPTER_NAME: &str = "middleware-spring"; + +fn callee_is_spring_middleware(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "preHandle" | "postHandle" | "afterCompletion" | "doFilter" | "addInterceptors" + ) +} + +fn source_imports_spring_middleware(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"HandlerInterceptor", + b"OncePerRequestFilter", + b"javax.servlet.Filter", + b"jakarta.servlet.Filter", + b"WebMvcConfigurer", + b"InterceptorRegistry", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for MiddlewareSpringAdapter { + 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 { + let matches_call = super::any_callee_matches(summary, callee_is_spring_middleware); + let matches_source = source_imports_spring_middleware(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::Middleware { + name: summary.name.clone(), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[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_spring_interceptor() { + let src: &[u8] = b"public class AuditInterceptor implements HandlerInterceptor {\n public boolean preHandle(Object req, Object res, Object handler) { return true; }\n}\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "preHandle".into(), + ..Default::default() + }; + let binding = MiddlewareSpringAdapter + .detect(&summary, tree.root_node(), src) + .expect("spring middleware binds"); + assert_eq!(binding.adapter, "middleware-spring"); + assert!(matches!(binding.kind, EntryKind::Middleware { .. })); + } +} diff --git a/src/dynamic/framework/adapters/migration_django.rs b/src/dynamic/framework/adapters/migration_django.rs new file mode 100644 index 00000000..5fbc4d0c --- /dev/null +++ b/src/dynamic/framework/adapters/migration_django.rs @@ -0,0 +1,119 @@ +//! Phase 21 (Track M.3) — Django migration adapter (Python). +//! +//! Fires when the surrounding source imports `django.db.migrations` and +//! declares a `Migration` class with `operations = [...]`. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct MigrationDjangoAdapter; + +const ADAPTER_NAME: &str = "migration-django"; + +fn callee_is_django_migration(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "CreateModel" + | "AddField" + | "AlterField" + | "DeleteModel" + | "RunPython" + | "RunSQL" + | "migrate" + ) +} + +fn source_imports_django_migration(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"django.db.migrations", + b"migrations.Migration", + b"migrations.RunPython", + b"operations = [", + b"dependencies = [", + b"from django.db import migrations", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_version(file_bytes: &[u8]) -> Option { + // Django migrations carry a numeric prefix on the filename + // (`0001_initial.py`); the version is more reliably the prefix of + // the file path, but we can also pull a top-level `# Version: NNNN` + // comment. Best-effort. + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + let needle = "# Generated by Django "; + if let Some(idx) = text.find(needle) { + let after = &text[idx + needle.len()..]; + if let Some(end) = after.find(|c: char| c == ' ' || c == '\n') { + return Some(after[..end].trim().to_owned()); + } + } + None +} + +impl FrameworkAdapter for MigrationDjangoAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Python + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_django_migration); + let matches_source = source_imports_django_migration(file_bytes); + if matches_call || matches_source { + 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(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_python(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_django_migration() { + let src: &[u8] = b"from django.db import migrations\n\ + class Migration(migrations.Migration):\n operations = [migrations.CreateModel(name='User', fields=[])]\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "Migration".into(), + ..Default::default() + }; + let binding = MigrationDjangoAdapter + .detect(&summary, tree.root_node(), src) + .expect("django migration binds"); + assert_eq!(binding.adapter, "migration-django"); + assert!(matches!(binding.kind, EntryKind::Migration { .. })); + } +} diff --git a/src/dynamic/framework/adapters/migration_flask.rs b/src/dynamic/framework/adapters/migration_flask.rs new file mode 100644 index 00000000..bd88ed22 --- /dev/null +++ b/src/dynamic/framework/adapters/migration_flask.rs @@ -0,0 +1,122 @@ +//! Phase 21 (Track M.3) — Flask-Migrate / Alembic migration adapter +//! (Python). +//! +//! Fires when the surrounding source imports `alembic` / `flask_migrate` +//! and declares an `upgrade()` / `downgrade()` revision function. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct MigrationFlaskAdapter; + +const ADAPTER_NAME: &str = "migration-flask"; + +fn callee_is_flask_migration(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "upgrade" + | "downgrade" + | "execute" + | "create_table" + | "add_column" + | "drop_table" + | "alter_column" + ) +} + +fn source_imports_flask_migration(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"from alembic", + b"import alembic", + b"flask_migrate", + b"op.create_table", + b"op.add_column", + b"op.execute", + b"revision = '", + b"revision = \"", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_version(file_bytes: &[u8]) -> Option { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + for needle in ["revision = '", "revision = \""] { + if let Some(idx) = text.find(needle) { + let after = &text[idx + needle.len()..]; + let close = if needle.ends_with('"') { '"' } else { '\'' }; + if let Some(end) = after.find(close) { + return Some(after[..end].to_owned()); + } + } + } + None +} + +impl FrameworkAdapter for MigrationFlaskAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Python + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_flask_migration); + let matches_source = source_imports_flask_migration(file_bytes); + if matches_call || matches_source { + 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(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_python(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_alembic_revision() { + let src: &[u8] = b"from alembic import op\nrevision = 'abc123'\n\ + def upgrade():\n op.create_table('users')\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "upgrade".into(), + ..Default::default() + }; + let binding = MigrationFlaskAdapter + .detect(&summary, tree.root_node(), src) + .expect("alembic binds"); + assert_eq!(binding.adapter, "migration-flask"); + if let EntryKind::Migration { version } = binding.kind { + assert_eq!(version.as_deref(), Some("abc123")); + } + } +} diff --git a/src/dynamic/framework/adapters/migration_laravel.rs b/src/dynamic/framework/adapters/migration_laravel.rs new file mode 100644 index 00000000..4d98fc78 --- /dev/null +++ b/src/dynamic/framework/adapters/migration_laravel.rs @@ -0,0 +1,95 @@ +//! Phase 21 (Track M.3) — Laravel migration adapter (PHP). +//! +//! Fires when the surrounding source extends `Illuminate\\Database\\Migrations\\Migration` +//! and declares an `up()` / `down()` method whose body invokes +//! `Schema::create` / `Schema::table` / `DB::statement`. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct MigrationLaravelAdapter; + +const ADAPTER_NAME: &str = "migration-laravel"; + +fn callee_is_laravel_migration(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "up" | "down" | "create" | "table" | "drop" | "statement" | "unprepared" + ) +} + +fn source_imports_laravel_migration(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"Illuminate\\Database\\Migrations\\Migration", + b"Illuminate\\Database\\Schema", + b"Schema::create", + b"Schema::table", + b"DB::statement", + b"use Illuminate\\Database\\Schema", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for MigrationLaravelAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Php + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_laravel_migration); + let matches_source = source_imports_laravel_migration(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::Migration { version: None }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_php(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_laravel_migration() { + let src: &[u8] = b" bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "$executeRaw" + | "$executeRawUnsafe" + | "$queryRaw" + | "$queryRawUnsafe" + | "migrate" + | "deploy" + | "up" + ) +} + +fn source_imports_prisma_migration(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"@prisma/client", + b"require('@prisma/client')", + b"require(\"@prisma/client\")", + b"from '@prisma/client'", + b"from \"@prisma/client\"", + b"prisma.$executeRaw", + b"prisma.$queryRaw", + b"PrismaClient", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for MigrationPrismaAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::JavaScript + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_prisma_migration); + let matches_source = source_imports_prisma_migration(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::Migration { version: None }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + 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 fires_on_prisma_raw_migration() { + let src: &[u8] = b"const { PrismaClient } = require('@prisma/client');\nconst prisma = new PrismaClient();\n\ + async function up(name) { await prisma.$executeRawUnsafe('CREATE TABLE ' + name); }\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "up".into(), + ..Default::default() + }; + let binding = MigrationPrismaAdapter + .detect(&summary, tree.root_node(), src) + .expect("prisma migration binds"); + assert_eq!(binding.adapter, "migration-prisma"); + assert!(matches!(binding.kind, EntryKind::Migration { .. })); + } +} diff --git a/src/dynamic/framework/adapters/migration_rails.rs b/src/dynamic/framework/adapters/migration_rails.rs new file mode 100644 index 00000000..80f0dc29 --- /dev/null +++ b/src/dynamic/framework/adapters/migration_rails.rs @@ -0,0 +1,118 @@ +//! Phase 21 (Track M.3) — Rails ActiveRecord migration adapter (Ruby). +//! +//! Fires when the surrounding source declares a class inheriting from +//! `ActiveRecord::Migration[...]` or invokes the canonical migration +//! DSL (`create_table`, `add_column`, `execute`). + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct MigrationRailsAdapter; + +const ADAPTER_NAME: &str = "migration-rails"; + +fn callee_is_rails_migration(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "up" + | "down" + | "change" + | "create_table" + | "add_column" + | "remove_column" + | "drop_table" + | "rename_column" + | "execute" + ) +} + +fn source_imports_rails_migration(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"ActiveRecord::Migration", + b"< ActiveRecord::Migration", + b"create_table ", + b"add_column ", + b"drop_table ", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_version(file_bytes: &[u8]) -> Option { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + let needle = "ActiveRecord::Migration["; + if let Some(idx) = text.find(needle) { + let after = &text[idx + needle.len()..]; + if let Some(end) = after.find(']') { + return Some(after[..end].trim().to_owned()); + } + } + None +} + +impl FrameworkAdapter for MigrationRailsAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Ruby + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_rails_migration); + let matches_source = source_imports_rails_migration(file_bytes); + if matches_call || matches_source { + 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(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_ruby(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_rails_migration() { + let src: &[u8] = b"class AddIndex < ActiveRecord::Migration[7.0]\n def up\n add_column :users, :name, :string\n end\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "up".into(), + ..Default::default() + }; + let binding = MigrationRailsAdapter + .detect(&summary, tree.root_node(), src) + .expect("rails migration binds"); + assert_eq!(binding.adapter, "migration-rails"); + if let EntryKind::Migration { version } = binding.kind { + assert_eq!(version.as_deref(), Some("7.0")); + } + } +} diff --git a/src/dynamic/framework/adapters/migration_sequelize.rs b/src/dynamic/framework/adapters/migration_sequelize.rs new file mode 100644 index 00000000..8665f07e --- /dev/null +++ b/src/dynamic/framework/adapters/migration_sequelize.rs @@ -0,0 +1,103 @@ +//! Phase 21 (Track M.3) — Sequelize migration adapter (JS). +//! +//! Fires when the surrounding source declares `module.exports = { up, down }` +//! whose `up` formal is `(queryInterface, Sequelize)` — Sequelize's +//! canonical migration shape — or imports the `sequelize` package. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct MigrationSequelizeAdapter; + +const ADAPTER_NAME: &str = "migration-sequelize"; + +fn callee_is_sequelize_migration(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "up" + | "down" + | "createTable" + | "addColumn" + | "dropTable" + | "removeColumn" + | "addIndex" + ) +} + +fn source_imports_sequelize_migration(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"require('sequelize')", + b"require(\"sequelize\")", + b"from 'sequelize'", + b"from \"sequelize\"", + b"queryInterface.createTable", + b"queryInterface.addColumn", + b"queryInterface.bulkInsert", + b"sequelize-cli", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +impl FrameworkAdapter for MigrationSequelizeAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::JavaScript + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_sequelize_migration); + let matches_source = source_imports_sequelize_migration(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::Migration { version: None }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + 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 fires_on_sequelize_migration() { + let src: &[u8] = b"module.exports = {\n async up(queryInterface, Sequelize) { await queryInterface.createTable('users', {}); },\n async down(queryInterface, Sequelize) { await queryInterface.dropTable('users'); }\n};\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "up".into(), + ..Default::default() + }; + let binding = MigrationSequelizeAdapter + .detect(&summary, tree.root_node(), src) + .expect("sequelize migration binds"); + assert_eq!(binding.adapter, "migration-sequelize"); + assert!(matches!(binding.kind, EntryKind::Migration { .. })); + } +} diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index fa6b5373..0a2fe08d 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -36,11 +36,27 @@ pub mod js_handlebars; pub mod js_koa; pub mod js_nest; pub mod js_routes; +pub mod graphql_apollo; +pub mod graphql_gqlgen; +pub mod graphql_graphene; +pub mod graphql_juniper; +pub mod graphql_relay; pub mod kafka_java; pub mod kafka_python; pub mod ldap_php; pub mod ldap_python; pub mod ldap_spring; +pub mod middleware_django; +pub mod middleware_express; +pub mod middleware_laravel; +pub mod middleware_rails; +pub mod middleware_spring; +pub mod migration_django; +pub mod migration_flask; +pub mod migration_laravel; +pub mod migration_prisma; +pub mod migration_rails; +pub mod migration_sequelize; pub mod nats_go; pub mod php_codeigniter; pub mod php_laravel; @@ -80,9 +96,17 @@ pub mod rust_axum; pub mod rust_rocket; pub mod rust_routes; pub mod rust_warp; +pub mod scheduled_celery; +pub mod scheduled_cron; +pub mod scheduled_quartz; +pub mod scheduled_sidekiq; pub mod sqs_java; pub mod sqs_node; pub mod sqs_python; +pub mod websocket_actioncable; +pub mod websocket_channels; +pub mod websocket_socketio; +pub mod websocket_ws; pub mod xpath_java; pub mod xpath_js; pub mod xpath_php; @@ -115,11 +139,27 @@ pub use js_fastify::JsFastifyAdapter; pub use js_handlebars::JsHandlebarsAdapter; pub use js_koa::JsKoaAdapter; pub use js_nest::{JsNestAdapter, TsNestAdapter}; +pub use graphql_apollo::GraphqlApolloAdapter; +pub use graphql_gqlgen::GraphqlGqlgenAdapter; +pub use graphql_graphene::GraphqlGrapheneAdapter; +pub use graphql_juniper::GraphqlJuniperAdapter; +pub use graphql_relay::GraphqlRelayAdapter; pub use kafka_java::KafkaJavaAdapter; pub use kafka_python::KafkaPythonAdapter; pub use ldap_php::LdapPhpAdapter; pub use ldap_python::LdapPythonAdapter; pub use ldap_spring::LdapSpringAdapter; +pub use middleware_django::MiddlewareDjangoAdapter; +pub use middleware_express::MiddlewareExpressAdapter; +pub use middleware_laravel::MiddlewareLaravelAdapter; +pub use middleware_rails::MiddlewareRailsAdapter; +pub use middleware_spring::MiddlewareSpringAdapter; +pub use migration_django::MigrationDjangoAdapter; +pub use migration_flask::MigrationFlaskAdapter; +pub use migration_laravel::MigrationLaravelAdapter; +pub use migration_prisma::MigrationPrismaAdapter; +pub use migration_rails::MigrationRailsAdapter; +pub use migration_sequelize::MigrationSequelizeAdapter; pub use nats_go::NatsGoAdapter; pub use php_codeigniter::PhpCodeIgniterAdapter; pub use php_laravel::PhpLaravelAdapter; @@ -155,9 +195,17 @@ pub use rust_actix::RustActixAdapter; pub use rust_axum::RustAxumAdapter; pub use rust_rocket::RustRocketAdapter; pub use rust_warp::RustWarpAdapter; +pub use scheduled_celery::ScheduledCeleryAdapter; +pub use scheduled_cron::ScheduledCronAdapter; +pub use scheduled_quartz::ScheduledQuartzAdapter; +pub use scheduled_sidekiq::ScheduledSidekiqAdapter; pub use sqs_java::SqsJavaAdapter; pub use sqs_node::SqsNodeAdapter; pub use sqs_python::SqsPythonAdapter; +pub use websocket_actioncable::WebsocketActionCableAdapter; +pub use websocket_channels::WebsocketChannelsAdapter; +pub use websocket_socketio::WebsocketSocketIoAdapter; +pub use websocket_ws::WebsocketWsAdapter; pub use xpath_java::XpathJavaAdapter; pub use xpath_js::XpathJsAdapter; pub use xpath_php::XpathPhpAdapter; diff --git a/src/dynamic/framework/adapters/scheduled_celery.rs b/src/dynamic/framework/adapters/scheduled_celery.rs new file mode 100644 index 00000000..3cb4eb78 --- /dev/null +++ b/src/dynamic/framework/adapters/scheduled_celery.rs @@ -0,0 +1,117 @@ +//! Phase 21 (Track M.3) — Python Celery scheduled-task adapter. +//! +//! Fires when the surrounding source imports Celery (`from celery`, +//! `import celery`) and the function body carries a `@app.task` / +//! `@shared_task` / `@celery.task` decorator or invokes a Celery +//! scheduling callee. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct ScheduledCeleryAdapter; + +const ADAPTER_NAME: &str = "scheduled-celery"; + +fn callee_is_celery(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "task" | "shared_task" | "apply_async" | "delay" | "add_periodic_task" + ) +} + +fn source_imports_celery(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"from celery", + b"import celery", + b"@app.task", + b"@celery.task", + b"@shared_task", + b"celery.schedules", + b"crontab(", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_schedule(file_bytes: &[u8]) -> Option { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + for needle in ["crontab(", "schedule=crontab(", "'schedule': crontab("] { + if let Some(idx) = text.find(needle) { + let after = &text[idx + needle.len()..]; + if let Some(end) = after.find(')') { + let inner = after[..end].trim(); + if !inner.is_empty() { + return Some(inner.to_owned()); + } + } + } + } + None +} + +impl FrameworkAdapter for ScheduledCeleryAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Python + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_celery); + let matches_source = source_imports_celery(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::ScheduledJob { + schedule: extract_schedule(file_bytes), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_python(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_celery_shared_task() { + let src: &[u8] = b"from celery import shared_task\n\ + @shared_task\n\ + def tick(payload):\n print(payload)\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "tick".into(), + ..Default::default() + }; + let binding = ScheduledCeleryAdapter + .detect(&summary, tree.root_node(), src) + .expect("celery binds"); + assert_eq!(binding.adapter, "scheduled-celery"); + assert!(matches!(binding.kind, EntryKind::ScheduledJob { .. })); + } +} diff --git a/src/dynamic/framework/adapters/scheduled_cron.rs b/src/dynamic/framework/adapters/scheduled_cron.rs new file mode 100644 index 00000000..dc09eb96 --- /dev/null +++ b/src/dynamic/framework/adapters/scheduled_cron.rs @@ -0,0 +1,146 @@ +//! Phase 21 (Track M.3) — Node cron scheduled-job adapter. +//! +//! Fires when the surrounding source imports a JavaScript cron library +//! (`node-cron`, `cron`, `node-schedule`) and the function body invokes +//! a job-scheduling callee. The binding's [`EntryKind::ScheduledJob`] +//! is stamped with a best-effort `schedule` extracted from the source +//! (a `cron.schedule('* * * * *', fn)` literal); a missing literal +//! falls back to `None`. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct ScheduledCronAdapter; + +const ADAPTER_NAME: &str = "scheduled-cron"; + +fn callee_is_cron(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "schedule" | "CronJob" | "scheduleJob" | "RecurrenceRule" | "job" + ) +} + +fn source_imports_cron(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"require('node-cron')", + b"require(\"node-cron\")", + b"from 'node-cron'", + b"from \"node-cron\"", + b"require('cron')", + b"require(\"cron\")", + b"from 'cron'", + b"from \"cron\"", + b"require('node-schedule')", + b"require(\"node-schedule\")", + b"from 'node-schedule'", + b"from \"node-schedule\"", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_schedule(file_bytes: &[u8]) -> Option { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + for needle in [ + "cron.schedule('", + "cron.schedule(\"", + "schedule.scheduleJob('", + "schedule.scheduleJob(\"", + "new CronJob('", + "new CronJob(\"", + ] { + if let Some(idx) = text.find(needle) { + let after = &text[idx + needle.len()..]; + let close = if needle.ends_with('"') { '"' } else { '\'' }; + if let Some(end) = after.find(close) { + return Some(after[..end].to_owned()); + } + } + } + None +} + +impl FrameworkAdapter for ScheduledCronAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::JavaScript + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_cron); + let matches_source = source_imports_cron(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::ScheduledJob { + schedule: extract_schedule(file_bytes), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + 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 fires_on_node_cron_schedule() { + let src: &[u8] = b"const cron = require('node-cron');\n\ + function tick(payload) { console.log(payload); }\n\ + cron.schedule('*/5 * * * *', tick);\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "tick".into(), + ..Default::default() + }; + let binding = ScheduledCronAdapter + .detect(&summary, tree.root_node(), src) + .expect("node-cron binds"); + assert_eq!(binding.adapter, "scheduled-cron"); + if let EntryKind::ScheduledJob { schedule } = binding.kind { + assert_eq!(schedule.as_deref(), Some("*/5 * * * *")); + } else { + panic!("expected ScheduledJob"); + } + } + + #[test] + fn skips_plain_function() { + let src: &[u8] = b"function add(a, b) { return a + b; }\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "add".into(), + ..Default::default() + }; + assert!(ScheduledCronAdapter + .detect(&summary, tree.root_node(), src) + .is_none()); + } +} diff --git a/src/dynamic/framework/adapters/scheduled_quartz.rs b/src/dynamic/framework/adapters/scheduled_quartz.rs new file mode 100644 index 00000000..d2388912 --- /dev/null +++ b/src/dynamic/framework/adapters/scheduled_quartz.rs @@ -0,0 +1,135 @@ +//! Phase 21 (Track M.3) — Java Quartz scheduled-job adapter. +//! +//! Fires when the surrounding source imports the Quartz scheduling API +//! (`org.quartz.*`, `@Scheduled` from Spring's task-scheduling package) +//! and the function body invokes / annotates a job-execution callee. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct ScheduledQuartzAdapter; + +const ADAPTER_NAME: &str = "scheduled-quartz"; + +fn callee_is_quartz(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "execute" | "scheduleJob" | "newJob" | "newTrigger" | "JobBuilder" | "TriggerBuilder" + ) +} + +fn source_imports_quartz(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"org.quartz", + b"@Scheduled", + b"org.springframework.scheduling", + b"import org.quartz", + b"implements Job", + b"@DisallowConcurrentExecution", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_schedule(file_bytes: &[u8]) -> Option { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + for needle in [ + "@Scheduled(cron = \"", + "@Scheduled(cron=\"", + "withSchedule(CronScheduleBuilder.cronSchedule(\"", + "cronSchedule(\"", + ] { + if let Some(idx) = text.find(needle) { + let after = &text[idx + needle.len()..]; + if let Some(end) = after.find('"') { + return Some(after[..end].to_owned()); + } + } + } + None +} + +impl FrameworkAdapter for ScheduledQuartzAdapter { + 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 { + let matches_call = super::any_callee_matches(summary, callee_is_quartz); + let matches_source = source_imports_quartz(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::ScheduledJob { + schedule: extract_schedule(file_bytes), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[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_quartz_job() { + let src: &[u8] = b"import org.quartz.Job;\n\ + public class TickJob implements Job {\n\ + public void execute(JobExecutionContext ctx) { }\n\ + }\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "execute".into(), + ..Default::default() + }; + let binding = ScheduledQuartzAdapter + .detect(&summary, tree.root_node(), src) + .expect("quartz binds"); + assert_eq!(binding.adapter, "scheduled-quartz"); + assert!(matches!(binding.kind, EntryKind::ScheduledJob { .. })); + } + + #[test] + fn extracts_spring_cron_schedule() { + let src: &[u8] = b"@Scheduled(cron = \"0 0 12 * * ?\")\n\ + public void tick() { }\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "tick".into(), + ..Default::default() + }; + let binding = ScheduledQuartzAdapter + .detect(&summary, tree.root_node(), src) + .expect("scheduled binds"); + if let EntryKind::ScheduledJob { schedule } = binding.kind { + assert_eq!(schedule.as_deref(), Some("0 0 12 * * ?")); + } + } +} diff --git a/src/dynamic/framework/adapters/scheduled_sidekiq.rs b/src/dynamic/framework/adapters/scheduled_sidekiq.rs new file mode 100644 index 00000000..86eaf1d1 --- /dev/null +++ b/src/dynamic/framework/adapters/scheduled_sidekiq.rs @@ -0,0 +1,125 @@ +//! Phase 21 (Track M.3) — Ruby Sidekiq worker / scheduled-job adapter. +//! +//! Fires when the surrounding source includes the Sidekiq worker +//! mixin (`include Sidekiq::Worker` / `Sidekiq::Job`) or invokes a +//! Sidekiq scheduling callee (`perform_async`, `perform_in`). + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct ScheduledSidekiqAdapter; + +const ADAPTER_NAME: &str = "scheduled-sidekiq"; + +fn callee_is_sidekiq(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "perform_async" | "perform_in" | "perform" | "set" + ) +} + +fn source_imports_sidekiq(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"include Sidekiq::Worker", + b"include Sidekiq::Job", + b"Sidekiq::Worker", + b"Sidekiq::Job", + b"require 'sidekiq'", + b"require \"sidekiq\"", + b"sidekiq_options", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_schedule(file_bytes: &[u8]) -> Option { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + for needle in [ + "sidekiq_options queue: :", + "sidekiq_options queue: \"", + "sidekiq_options queue: '", + ] { + if let Some(idx) = text.find(needle) { + let after = &text[idx + needle.len()..]; + let close: &[char] = if needle.ends_with(':') { + &[',', '\n'] + } else if needle.ends_with('"') { + &['"'] + } else { + &['\''] + }; + if let Some(end) = after.find(|c: char| close.contains(&c)) { + let v = after[..end].trim(); + if !v.is_empty() { + return Some(v.to_owned()); + } + } + } + } + None +} + +impl FrameworkAdapter for ScheduledSidekiqAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Ruby + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_sidekiq); + let matches_source = source_imports_sidekiq(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::ScheduledJob { + schedule: extract_schedule(file_bytes), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_ruby(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_sidekiq_worker() { + let src: &[u8] = b"class TickWorker\n include Sidekiq::Worker\n def perform(payload)\n puts payload\n end\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "perform".into(), + ..Default::default() + }; + let binding = ScheduledSidekiqAdapter + .detect(&summary, tree.root_node(), src) + .expect("sidekiq binds"); + assert_eq!(binding.adapter, "scheduled-sidekiq"); + assert!(matches!(binding.kind, EntryKind::ScheduledJob { .. })); + } +} diff --git a/src/dynamic/framework/adapters/websocket_actioncable.rs b/src/dynamic/framework/adapters/websocket_actioncable.rs new file mode 100644 index 00000000..15588b51 --- /dev/null +++ b/src/dynamic/framework/adapters/websocket_actioncable.rs @@ -0,0 +1,113 @@ +//! Phase 21 (Track M.3) — Rails ActionCable WebSocket adapter (Ruby). +//! +//! Fires when the surrounding source declares an `ApplicationCable` / +//! `ActionCable::Channel::Base` subclass and the function body sits on +//! a `receive` / `subscribed` / `unsubscribed` callback. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct WebsocketActionCableAdapter; + +const ADAPTER_NAME: &str = "websocket-actioncable"; + +fn callee_is_actioncable(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "receive" | "subscribed" | "unsubscribed" | "transmit" | "broadcast" + ) +} + +fn source_imports_actioncable(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"ApplicationCable::Channel", + b"ActionCable::Channel::Base", + b"< ApplicationCable", + b"< ActionCable::Channel", + b"require 'action_cable'", + b"require \"action_cable\"", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_path(file_bytes: &[u8]) -> String { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + for needle in ["stream_from '", "stream_from \"", "stream_for '", "stream_for \""] { + if let Some(idx) = text.find(needle) { + let after = &text[idx + needle.len()..]; + let close = if needle.ends_with('"') { '"' } else { '\'' }; + if let Some(end) = after.find(close) { + return after[..end].to_owned(); + } + } + } + "/cable".to_owned() +} + +impl FrameworkAdapter for WebsocketActionCableAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Ruby + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_actioncable); + let matches_source = source_imports_actioncable(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::WebSocket { + path: extract_path(file_bytes), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_ruby(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_actioncable_channel() { + let src: &[u8] = b"class ChatChannel < ApplicationCable::Channel\n def subscribed\n stream_from 'chat_room'\n end\n def receive(data)\n end\nend\n"; + let tree = parse_ruby(src); + let summary = FuncSummary { + name: "receive".into(), + ..Default::default() + }; + let binding = WebsocketActionCableAdapter + .detect(&summary, tree.root_node(), src) + .expect("action_cable binds"); + assert_eq!(binding.adapter, "websocket-actioncable"); + if let EntryKind::WebSocket { path } = binding.kind { + assert_eq!(path, "chat_room"); + } + } +} diff --git a/src/dynamic/framework/adapters/websocket_channels.rs b/src/dynamic/framework/adapters/websocket_channels.rs new file mode 100644 index 00000000..6e08117d --- /dev/null +++ b/src/dynamic/framework/adapters/websocket_channels.rs @@ -0,0 +1,112 @@ +//! Phase 21 (Track M.3) — Django Channels WebSocket adapter (Python). +//! +//! Fires when the surrounding source imports Django Channels +//! (`channels.generic.websocket`, `AsyncWebsocketConsumer`) and the +//! function body sits inside a `WebsocketConsumer` subclass. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct WebsocketChannelsAdapter; + +const ADAPTER_NAME: &str = "websocket-channels"; + +fn callee_is_channels(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "receive" | "receive_json" | "connect" | "disconnect" | "send" | "send_json" + ) +} + +fn source_imports_channels(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"channels.generic.websocket", + b"WebsocketConsumer", + b"AsyncWebsocketConsumer", + b"JsonWebsocketConsumer", + b"AsyncJsonWebsocketConsumer", + b"from channels", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_path(file_bytes: &[u8]) -> String { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + for needle in ["re_path(r'", "re_path('", "path('", "path(\""] { + if let Some(idx) = text.find(needle) { + let after = &text[idx + needle.len()..]; + let close: &[char] = &['\'', '"']; + if let Some(end) = after.find(|c: char| close.contains(&c)) { + return after[..end].to_owned(); + } + } + } + "/ws/".to_owned() +} + +impl FrameworkAdapter for WebsocketChannelsAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Python + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_channels); + let matches_source = source_imports_channels(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::WebSocket { + path: extract_path(file_bytes), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_python(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_channels_consumer() { + let src: &[u8] = b"from channels.generic.websocket import WebsocketConsumer\n\ + class ChatConsumer(WebsocketConsumer):\n def receive(self, text_data=None, bytes_data=None):\n pass\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "receive".into(), + ..Default::default() + }; + let binding = WebsocketChannelsAdapter + .detect(&summary, tree.root_node(), src) + .expect("channels binds"); + assert_eq!(binding.adapter, "websocket-channels"); + assert!(matches!(binding.kind, EntryKind::WebSocket { .. })); + } +} diff --git a/src/dynamic/framework/adapters/websocket_socketio.rs b/src/dynamic/framework/adapters/websocket_socketio.rs new file mode 100644 index 00000000..1ea21d80 --- /dev/null +++ b/src/dynamic/framework/adapters/websocket_socketio.rs @@ -0,0 +1,116 @@ +//! Phase 21 (Track M.3) — Socket.IO WebSocket adapter (Python). +//! +//! Fires when the surrounding source imports `python-socketio` / +//! `socketio` and the function body is registered against an `on(...)` +//! event name. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct WebsocketSocketIoAdapter; + +const ADAPTER_NAME: &str = "websocket-socketio"; + +fn callee_is_socketio(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "on" | "emit" | "send" | "AsyncServer" | "Server" | "event" + ) +} + +fn source_imports_socketio(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"import socketio", + b"from socketio", + b"socketio.Server", + b"socketio.AsyncServer", + b"@sio.event", + b"@sio.on(", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_path(file_bytes: &[u8]) -> String { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + for needle in ["sio.on('", "sio.on(\"", "@sio.on('", "@sio.on(\""] { + if let Some(idx) = text.find(needle) { + let after = &text[idx + needle.len()..]; + let close = if needle.ends_with('"') { '"' } else { '\'' }; + if let Some(end) = after.find(close) { + return after[..end].to_owned(); + } + } + } + "/".to_owned() +} + +impl FrameworkAdapter for WebsocketSocketIoAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Python + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_socketio); + let matches_source = source_imports_socketio(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::WebSocket { + path: extract_path(file_bytes), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_python(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn fires_on_socketio_event() { + let src: &[u8] = b"import socketio\n\ + sio = socketio.Server()\n\ + @sio.on('message')\n\ + def message(sid, data):\n pass\n"; + let tree = parse_python(src); + let summary = FuncSummary { + name: "message".into(), + ..Default::default() + }; + let binding = WebsocketSocketIoAdapter + .detect(&summary, tree.root_node(), src) + .expect("socketio binds"); + assert_eq!(binding.adapter, "websocket-socketio"); + if let EntryKind::WebSocket { path } = binding.kind { + assert_eq!(path, "message"); + } + } +} diff --git a/src/dynamic/framework/adapters/websocket_ws.rs b/src/dynamic/framework/adapters/websocket_ws.rs new file mode 100644 index 00000000..e81a6456 --- /dev/null +++ b/src/dynamic/framework/adapters/websocket_ws.rs @@ -0,0 +1,116 @@ +//! Phase 21 (Track M.3) — `ws` (Node WebSocket) adapter. +//! +//! Fires when the surrounding source requires/imports the `ws` package +//! and the function body is the `on('message', ...)` listener on a +//! `WebSocket.Server` / `WebSocketServer` instance. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct WebsocketWsAdapter; + +const ADAPTER_NAME: &str = "websocket-ws"; + +fn callee_is_ws(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "WebSocket" | "WebSocketServer" | "Server" | "on" | "send" + ) +} + +fn source_imports_ws(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"require('ws')", + b"require(\"ws\")", + b"from 'ws'", + b"from \"ws\"", + b"new WebSocketServer", + b"new WebSocket.Server", + b"WebSocket.Server", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn extract_path(file_bytes: &[u8]) -> String { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + for needle in ["path: '", "path: \"", "path:'", "path:\""] { + if let Some(idx) = text.find(needle) { + let after = &text[idx + needle.len()..]; + let close = if needle.ends_with('"') { '"' } else { '\'' }; + if let Some(end) = after.find(close) { + return after[..end].to_owned(); + } + } + } + "/".to_owned() +} + +impl FrameworkAdapter for WebsocketWsAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::JavaScript + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let matches_call = super::any_callee_matches(summary, callee_is_ws); + let matches_source = source_imports_ws(file_bytes); + if matches_call || matches_source { + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::WebSocket { + path: extract_path(file_bytes), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } else { + 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 fires_on_ws_server() { + let src: &[u8] = b"const { WebSocketServer } = require('ws');\n\ + const wss = new WebSocketServer({ port: 0, path: '/feed' });\n\ + function onMessage(data) { }\n"; + let tree = parse_js(src); + let summary = FuncSummary { + name: "onMessage".into(), + ..Default::default() + }; + let binding = WebsocketWsAdapter + .detect(&summary, tree.root_node(), src) + .expect("ws binds"); + assert_eq!(binding.adapter, "websocket-ws"); + if let EntryKind::WebSocket { path } = binding.kind { + assert_eq!(path, "/feed"); + } + } +} diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index 0854020f..0a09cf44 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -214,18 +214,31 @@ mod tests { } #[test] - fn registry_baseline_after_phase_20() { - // Phase 20 (Track M.2) adds 10 MessageHandler-flavoured - // framework adapters distributed across Java (3 — Kafka, - // RabbitMQ, SQS), Python (4 — Kafka, Pub/Sub, RabbitMQ, SQS), - // Go (2 — Pub/Sub, NATS), and JavaScript (1 — SQS). The - // Phase 17 baseline for the other languages stays put: Php 10, - // Ruby 8, TypeScript 4, Rust 6, C/Cpp empty. + fn registry_baseline_after_phase_21() { + // Phase 21 (Track M.3) adds the remaining five `EntryKind` + // variants — `ScheduledJob` / `GraphQLResolver` / `WebSocket` + // / `Middleware` / `Migration` — distributed across the + // language slices. Per-lang deltas vs the Phase 20 baseline: + // Java: +2 (ScheduledQuartz, MiddlewareSpring) 14 → 16 + // Php: +2 (MiddlewareLaravel, MigrationLaravel) 10 → 12 + // Python: +7 (GraphqlGraphene, MiddlewareDjango, + // MigrationDjango, MigrationFlask, + // ScheduledCelery, WebsocketChannels, + // WebsocketSocketIo) 15 → 22 + // Ruby: +4 (MiddlewareRails, MigrationRails, + // ScheduledSidekiq, WebsocketActionCable) 8 → 12 + // JavaScript: +7 (GraphqlApollo, GraphqlRelay, + // MiddlewareExpress, MigrationPrisma, + // MigrationSequelize, ScheduledCron, + // WebsocketWs) 12 → 19 + // Go: +1 (GraphqlGqlgen) 9 → 10 + // Rust: +1 (GraphqlJuniper) 6 → 7 + // TypeScript / C / Cpp stay unchanged. let java_registered = registry::adapters_for(Lang::Java); assert_eq!( java_registered.len(), - 14, - "Java must have Phase 17 baseline (11) + M.2 Kafka/Rabbit/SQS (3)", + 16, + "Java must have Phase 20 baseline (14) + M.3 Quartz/Spring-middleware (2)", ); for adapter in java_registered { assert_eq!(adapter.lang(), Lang::Java); @@ -233,8 +246,8 @@ mod tests { let php_registered = registry::adapters_for(Lang::Php); assert_eq!( php_registered.len(), - 10, - "Php must have J.1..J.7 (7) + L.14 Laravel/Symfony/CodeIgniter (3) adapters", + 12, + "Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2)", ); for adapter in php_registered { assert_eq!(adapter.lang(), Lang::Php); @@ -242,8 +255,8 @@ mod tests { let python_registered = registry::adapters_for(Lang::Python); assert_eq!( python_registered.len(), - 15, - "Python must have Phase 17 baseline (11) + M.2 Kafka/Pub-Sub/Rabbit/SQS (4)", + 22, + "Python must have Phase 20 baseline (15) + M.3 Phase-21 (7)", ); for adapter in python_registered { assert_eq!(adapter.lang(), Lang::Python); @@ -251,8 +264,8 @@ mod tests { let ruby_registered = registry::adapters_for(Lang::Ruby); assert_eq!( ruby_registered.len(), - 8, - "Ruby must have the J.1 + J.2 + J.3 + J.6 + J.7 (5) + L.13 Rails/Sinatra/Hanami (3) adapters", + 12, + "Ruby must have Phase 20 baseline (8) + M.3 Phase-21 (4)", ); for adapter in ruby_registered { assert_eq!(adapter.lang(), Lang::Ruby); @@ -260,8 +273,8 @@ mod tests { let js_registered = registry::adapters_for(Lang::JavaScript); assert_eq!( js_registered.len(), - 12, - "JavaScript must have Phase 17 baseline (11) + M.2 sqs-node (1)", + 19, + "JavaScript must have Phase 20 baseline (12) + M.3 Phase-21 (7)", ); for adapter in js_registered { assert_eq!(adapter.lang(), Lang::JavaScript); @@ -270,7 +283,7 @@ mod tests { assert_eq!( ts_registered.len(), 4, - "TypeScript must have the J.8(×3) prototype-pollution adapters + L.11 ts-nest", + "TypeScript stays at Phase 20 baseline (4)", ); for adapter in ts_registered { assert_eq!(adapter.lang(), Lang::TypeScript); @@ -278,8 +291,8 @@ mod tests { let go_registered = registry::adapters_for(Lang::Go); assert_eq!( go_registered.len(), - 9, - "Go must have Phase 17 baseline (7) + M.2 pubsub-go/nats-go (2)", + 10, + "Go must have Phase 20 baseline (9) + M.3 gqlgen (1)", ); for adapter in go_registered { assert_eq!(adapter.lang(), Lang::Go); @@ -287,8 +300,8 @@ mod tests { let rust_registered = registry::adapters_for(Lang::Rust); assert_eq!( rust_registered.len(), - 6, - "Rust must have the J.6 + J.7 (2) + L.15 actix/axum/rocket/warp (4) adapters", + 7, + "Rust must have Phase 20 baseline (6) + M.3 juniper (1)", ); for adapter in rust_registered { assert_eq!(adapter.lang(), Lang::Rust); diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs index 3b27a9f4..99cd7e08 100644 --- a/src/dynamic/framework/registry.rs +++ b/src/dynamic/framework/registry.rs @@ -45,6 +45,7 @@ pub fn adapters_for(lang: Lang) -> &'static [&'static dyn FrameworkAdapter] { // later phase that appends a new adapter cannot silently re-order // the existing first-match. static RUST: &[&dyn FrameworkAdapter] = &[ + &super::adapters::GraphqlJuniperAdapter, &super::adapters::HeaderRustAdapter, &super::adapters::RedirectRustAdapter, &super::adapters::RustActixAdapter, @@ -64,8 +65,10 @@ static JAVA: &[&dyn FrameworkAdapter] = &[ &super::adapters::JavaThymeleafAdapter, &super::adapters::KafkaJavaAdapter, &super::adapters::LdapSpringAdapter, + &super::adapters::MiddlewareSpringAdapter, &super::adapters::RabbitJavaAdapter, &super::adapters::RedirectJavaAdapter, + &super::adapters::ScheduledQuartzAdapter, &super::adapters::SqsJavaAdapter, &super::adapters::XpathJavaAdapter, &super::adapters::XxeJavaAdapter, @@ -75,6 +78,7 @@ static GO: &[&dyn FrameworkAdapter] = &[ &super::adapters::GoEchoAdapter, &super::adapters::GoFiberAdapter, &super::adapters::GoGinAdapter, + &super::adapters::GraphqlGqlgenAdapter, &super::adapters::HeaderGoAdapter, &super::adapters::NatsGoAdapter, &super::adapters::PubsubGoAdapter, @@ -84,6 +88,8 @@ static GO: &[&dyn FrameworkAdapter] = &[ static PHP: &[&dyn FrameworkAdapter] = &[ &super::adapters::HeaderPhpAdapter, &super::adapters::LdapPhpAdapter, + &super::adapters::MiddlewareLaravelAdapter, + &super::adapters::MigrationLaravelAdapter, &super::adapters::PhpCodeIgniterAdapter, &super::adapters::PhpLaravelAdapter, &super::adapters::PhpSymfonyAdapter, @@ -94,9 +100,13 @@ static PHP: &[&dyn FrameworkAdapter] = &[ &super::adapters::XxePhpAdapter, ]; static PYTHON: &[&dyn FrameworkAdapter] = &[ + &super::adapters::GraphqlGrapheneAdapter, &super::adapters::HeaderPythonAdapter, &super::adapters::KafkaPythonAdapter, &super::adapters::LdapPythonAdapter, + &super::adapters::MiddlewareDjangoAdapter, + &super::adapters::MigrationDjangoAdapter, + &super::adapters::MigrationFlaskAdapter, &super::adapters::PubsubPythonAdapter, &super::adapters::PythonDjangoAdapter, &super::adapters::PythonFastApiAdapter, @@ -106,18 +116,25 @@ static PYTHON: &[&dyn FrameworkAdapter] = &[ &super::adapters::PythonStarletteAdapter, &super::adapters::RabbitPythonAdapter, &super::adapters::RedirectPythonAdapter, + &super::adapters::ScheduledCeleryAdapter, &super::adapters::SqsPythonAdapter, + &super::adapters::WebsocketChannelsAdapter, + &super::adapters::WebsocketSocketIoAdapter, &super::adapters::XpathPythonAdapter, &super::adapters::XxePythonAdapter, ]; static RUBY: &[&dyn FrameworkAdapter] = &[ &super::adapters::HeaderRubyAdapter, + &super::adapters::MiddlewareRailsAdapter, + &super::adapters::MigrationRailsAdapter, &super::adapters::RedirectRubyAdapter, &super::adapters::RubyErbAdapter, &super::adapters::RubyHanamiAdapter, &super::adapters::RubyMarshalAdapter, &super::adapters::RubyRailsAdapter, &super::adapters::RubySinatraAdapter, + &super::adapters::ScheduledSidekiqAdapter, + &super::adapters::WebsocketActionCableAdapter, &super::adapters::XxeRubyAdapter, ]; static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[ @@ -127,16 +144,23 @@ static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[ &super::adapters::TsNestAdapter, ]; static JAVASCRIPT: &[&dyn FrameworkAdapter] = &[ + &super::adapters::GraphqlApolloAdapter, + &super::adapters::GraphqlRelayAdapter, &super::adapters::HeaderJsAdapter, &super::adapters::JsExpressAdapter, &super::adapters::JsFastifyAdapter, &super::adapters::JsHandlebarsAdapter, &super::adapters::JsKoaAdapter, &super::adapters::JsNestAdapter, + &super::adapters::MiddlewareExpressAdapter, + &super::adapters::MigrationPrismaAdapter, + &super::adapters::MigrationSequelizeAdapter, &super::adapters::PpJsonDeepAssignJsAdapter, &super::adapters::PpLodashMergeJsAdapter, &super::adapters::PpObjectAssignJsAdapter, &super::adapters::RedirectJsAdapter, + &super::adapters::ScheduledCronAdapter, &super::adapters::SqsNodeAdapter, + &super::adapters::WebsocketWsAdapter, &super::adapters::XpathJsAdapter, ]; diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index caeb194c..f0dcb8c5 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -57,6 +57,7 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::CliSubcommand, EntryKindTag::ClassMethod, EntryKindTag::MessageHandler, + EntryKindTag::GraphQLResolver, ]; impl LangEmitter for GoEmitter { @@ -592,6 +593,11 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_message_handler_harness(spec, queue)); } + // Phase 21 (Track M.3): GraphQLResolver short-circuit (gqlgen). + if let crate::evidence::EntryKind::GraphQLResolver { type_name, field } = &spec.entry_kind { + return Ok(emit_graphql_resolver_harness(&spec.entry_name, type_name, field)); + } + let entry_source = read_entry_source(&spec.entry_file); let shape = GoShape::detect(spec, &entry_source); let main_go = generate_main_go(spec, shape); @@ -1269,6 +1275,85 @@ func main() {{ } } +// ── Phase 21 (Track M.3) — synthetic entry-kind harnesses ───────────────────── + +/// Phase 21 (Track M.3) — GraphQL resolver harness for Go (gqlgen). +/// +/// Looks up the named resolver via the entry package's `NyxResolvers` +/// map (mirrors the `NyxReceivers` / `NyxHandlers` contracts from +/// Phase 19 / 20), constructs a synthetic `context.Background()`, and +/// invokes the resolver with the payload positionally. +fn emit_graphql_resolver_harness(handler: &str, type_name: &str, field: &str) -> HarnessSource { + let shim = probe_shim(); + let go_mod = generate_go_mod(); + let source = format!( + r##"// Nyx dynamic harness — GraphQL resolver (Phase 21 / Track M.3). +package main + +import ( + "context" + "fmt" + "os" + "reflect" + + "nyx-harness/entry" +) + +{shim} + +func nyxPayload() string {{ + if v := os.Getenv("NYX_PAYLOAD"); v != "" {{ + return v + }} + return "" +}} + +func main() {{ + __nyx_install_crash_guard("{type_name}.{field}") + payload := nyxPayload() + fmt.Println("__NYX_GRAPHQL_RESOLVER__: " + "{type_name}" + "." + "{field}") + fmt.Println("__NYX_SINK_HIT__") + cb, ok := entry.NyxResolvers["{handler}"] + if !ok {{ + fmt.Fprintln(os.Stderr, "NYX_RESOLVER_NOT_FOUND: " + "{handler}") + os.Exit(78) + }} + v := reflect.ValueOf(cb) + args := make([]reflect.Value, v.Type().NumIn()) + for i := 0; i < v.Type().NumIn(); i++ {{ + want := v.Type().In(i) + if want.Kind() == reflect.String {{ + args[i] = reflect.ValueOf(payload) + }} else if want.String() == "context.Context" {{ + args[i] = reflect.ValueOf(context.Background()) + }} else {{ + args[i] = reflect.Zero(want) + }} + }} + defer func() {{ + if r := recover(); r != nil {{ + fmt.Fprintf(os.Stderr, "NYX_EXCEPTION: panic: %v\n", r) + }} + }}() + out := v.Call(args) + if len(out) > 0 {{ + fmt.Println(out[0].Interface()) + }} +}} +"##, + handler = handler, + type_name = type_name, + field = field, + ); + HarnessSource { + source, + filename: "main.go".to_owned(), + command: vec!["./nyx_harness".to_owned()], + extra_files: vec![("go.mod".to_owned(), go_mod)], + entry_subpath: Some("entry/entry.go".to_owned()), + } +} + #[derive(Debug, Clone, Copy)] enum GoBroker { Pubsub, diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index ac4facd9..1466cbb7 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -56,6 +56,8 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::CliSubcommand, EntryKindTag::ClassMethod, EntryKindTag::MessageHandler, + EntryKindTag::ScheduledJob, + EntryKindTag::Middleware, ]; impl LangEmitter for JavaEmitter { @@ -611,6 +613,20 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_message_handler_harness(spec, queue, &entry_class)); } + // Phase 21 (Track M.3): ScheduledJob short-circuit (Quartz). + if let crate::evidence::EntryKind::ScheduledJob { schedule } = &spec.entry_kind { + let entry_source = read_entry_source(&spec.entry_file); + let entry_class = derive_entry_class(&entry_source); + return Ok(emit_scheduled_job_harness(spec, schedule.as_deref(), &entry_class)); + } + + // Phase 21 (Track M.3): Middleware short-circuit (Spring HandlerInterceptor / Filter). + if let crate::evidence::EntryKind::Middleware { name } = &spec.entry_kind { + let entry_source = read_entry_source(&spec.entry_file); + let entry_class = derive_entry_class(&entry_source); + return Ok(emit_middleware_harness(spec, name, &entry_class)); + } + let entry_source = read_entry_source(&spec.entry_file); let shape = JavaShape::detect(spec, &entry_source); let entry_class = derive_entry_class(&entry_source); @@ -2103,6 +2119,165 @@ public class NyxHarness {{ } } +// ── Phase 21 (Track M.3) — synthetic entry-kind harnesses ───────────────────── + +fn emit_scheduled_job_harness( + spec: &HarnessSpec, + schedule: Option<&str>, + entry_class: &str, +) -> HarnessSource { + let probe = probe_shim(); + let pre_call = pre_call_setup(spec); + let method = &spec.entry_name; + let schedule_repr = schedule.unwrap_or(""); + let source = format!( + r#"// Nyx dynamic harness — scheduled job (Phase 21 / Track M.3). +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.InvocationTargetException; + +public class NyxHarness {{ +{probe} + + public static void main(String[] args) {{ + String payload = nyxPayload(); +{pre_call} System.out.println("__NYX_SCHEDULED_JOB__: " + {schedule:?}); + System.out.println("__NYX_SINK_HIT__"); + try {{ + Class cls = Class.forName({entry_class:?}); + Constructor ctor = cls.getDeclaredConstructor(); + ctor.setAccessible(true); + Object instance = ctor.newInstance(); + Method m = null; + for (Method candidate : cls.getDeclaredMethods()) {{ + if (candidate.getName().equals({method:?})) {{ m = candidate; break; }} + }} + if (m == null) {{ + System.err.println("NYX_METHOD_NOT_FOUND: " + {method:?}); + System.exit(78); + }} + m.setAccessible(true); + Class[] params = m.getParameterTypes(); + Object[] mArgs = new Object[params.length]; + for (int i = 0; i < params.length; i++) {{ + mArgs[i] = params[i].equals(String.class) ? payload : null; + }} + m.invoke(instance, mArgs); + }} catch (InvocationTargetException ite) {{ + Throwable cause = ite.getCause() == null ? ite : ite.getCause(); + System.err.println("NYX_EXCEPTION: " + cause.getClass().getName() + ": " + cause.getMessage()); + }} catch (Throwable e) {{ + System.err.println("NYX_EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage()); + }} + }} + + static String nyxPayload() {{ + String v = System.getenv("NYX_PAYLOAD"); + if (v != null && !v.isEmpty()) return v; + String b64 = System.getenv("NYX_PAYLOAD_B64"); + if (b64 != null && !b64.isEmpty()) {{ + byte[] decoded = java.util.Base64.getDecoder().decode(b64); + return new String(decoded, java.nio.charset.StandardCharsets.UTF_8); + }} + return ""; + }} +}} +"#, + entry_class = entry_class, + method = method, + schedule = schedule_repr, + pre_call = pre_call, + ); + HarnessSource { + source, + filename: "NyxHarness.java".to_owned(), + command: vec![ + "java".to_owned(), + "-cp".to_owned(), + ".".to_owned(), + "NyxHarness".to_owned(), + ], + extra_files: vec![], + entry_subpath: Some(format!("{entry_class}.java")), + } +} + +fn emit_middleware_harness(spec: &HarnessSpec, name: &str, entry_class: &str) -> HarnessSource { + let probe = probe_shim(); + let pre_call = pre_call_setup(spec); + let method = &spec.entry_name; + let source = format!( + r#"// Nyx dynamic harness — middleware (Phase 21 / Track M.3). +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.InvocationTargetException; + +public class NyxHarness {{ +{probe} + + public static void main(String[] args) {{ + String payload = nyxPayload(); +{pre_call} System.out.println("__NYX_MIDDLEWARE__: " + {name:?}); + System.out.println("__NYX_SINK_HIT__"); + try {{ + Class cls = Class.forName({entry_class:?}); + Constructor ctor = cls.getDeclaredConstructor(); + ctor.setAccessible(true); + Object instance = ctor.newInstance(); + Method m = null; + for (Method candidate : cls.getDeclaredMethods()) {{ + if (candidate.getName().equals({method:?})) {{ m = candidate; break; }} + }} + if (m == null) {{ + System.err.println("NYX_METHOD_NOT_FOUND: " + {method:?}); + System.exit(78); + }} + m.setAccessible(true); + Class[] params = m.getParameterTypes(); + Object[] mArgs = new Object[params.length]; + for (int i = 0; i < params.length; i++) {{ + mArgs[i] = params[i].equals(String.class) ? payload : null; + }} + m.invoke(instance, mArgs); + }} catch (InvocationTargetException ite) {{ + Throwable cause = ite.getCause() == null ? ite : ite.getCause(); + System.err.println("NYX_EXCEPTION: " + cause.getClass().getName() + ": " + cause.getMessage()); + }} catch (Throwable e) {{ + System.err.println("NYX_EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage()); + }} + }} + + static String nyxPayload() {{ + String v = System.getenv("NYX_PAYLOAD"); + if (v != null && !v.isEmpty()) return v; + String b64 = System.getenv("NYX_PAYLOAD_B64"); + if (b64 != null && !b64.isEmpty()) {{ + byte[] decoded = java.util.Base64.getDecoder().decode(b64); + return new String(decoded, java.nio.charset.StandardCharsets.UTF_8); + }} + return ""; + }} +}} +"#, + entry_class = entry_class, + method = method, + name = name, + pre_call = pre_call, + ); + HarnessSource { + source, + filename: "NyxHarness.java".to_owned(), + command: vec![ + "java".to_owned(), + "-cp".to_owned(), + ".".to_owned(), + "NyxHarness".to_owned(), + ], + extra_files: vec![], + entry_subpath: Some(format!("{entry_class}.java")), + } +} + #[derive(Debug, Clone, Copy)] enum JavaBroker { Kafka, diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 5666d5e8..914d5c86 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -583,6 +583,31 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result {{ } } +// ── Phase 21 (Track M.3) — synthetic entry-kind harnesses ───────────────────── + +fn nyx_js_preamble(spec: &HarnessSpec, is_typescript: bool) -> (String, String) { + let probe = probe_shim(); + let entry_subpath = if is_typescript { "entry.ts" } else { "entry.js" }; + let require_path = entry_require_path(entry_subpath); + let preamble = format!( + r#"'use strict'; +{probe} + +const payload = (process.env.NYX_PAYLOAD && process.env.NYX_PAYLOAD.length > 0) + ? process.env.NYX_PAYLOAD + : (process.env.NYX_PAYLOAD_B64 + ? Buffer.from(process.env.NYX_PAYLOAD_B64, 'base64').toString('utf8') + : ''); + +let _entry; +try {{ + _entry = require('./{require_path}'); +}} catch (e) {{ + process.stderr.write('NYX_IMPORT_ERROR: ' + e.message + '\n'); + process.exit(77); +}} + +function _nyxResolve(name) {{ + const _h = _entry[name] + || (_entry.default && _entry.default[name]) + || (typeof _entry.default === 'function' && _entry.default.name === name ? _entry.default : null); + return (typeof _h === 'function') ? _h : null; +}} + +process.stdout.write('__NYX_SINK_HIT__\n'); +"#, + probe = probe, + require_path = require_path, + ); + let _ = spec; + (preamble, entry_subpath.to_owned()) +} + +fn emit_scheduled_job(spec: &HarnessSpec, schedule: Option<&str>, is_typescript: bool) -> HarnessSource { + let (preamble, entry_subpath) = nyx_js_preamble(spec, is_typescript); + let handler = &spec.entry_name; + let schedule_repr = schedule.unwrap_or(""); + let body = format!( + r#"{preamble} +// Phase 21 (Track M.3) — scheduled job. +process.stdout.write('__NYX_SCHEDULED_JOB__: ' + {schedule:?} + '\n'); +const _h = _nyxResolve({handler:?}); +if (_h == null) {{ + process.stderr.write('NYX_HANDLER_NOT_FOUND: ' + {handler:?} + '\n'); + process.exit(78); +}} +(async () => {{ + try {{ + const _result = await Promise.resolve(_h(payload)); + if (_result != null) process.stdout.write(String(_result) + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"#, + preamble = preamble, + handler = handler, + schedule = schedule_repr, + ); + HarnessSource { + source: body, + filename: "harness.js".to_owned(), + command: vec!["node".to_owned(), "harness.js".to_owned()], + extra_files: Vec::new(), + entry_subpath: Some(entry_subpath), + } +} + +fn emit_graphql_resolver( + spec: &HarnessSpec, + type_name: &str, + field: &str, + is_typescript: bool, +) -> HarnessSource { + let (preamble, entry_subpath) = nyx_js_preamble(spec, is_typescript); + let handler = &spec.entry_name; + let body = format!( + r#"{preamble} +// Phase 21 (Track M.3) — GraphQL resolver. +process.stdout.write('__NYX_GRAPHQL_RESOLVER__: ' + {type_name:?} + '.' + {field:?} + '\n'); +const _h = _nyxResolve({handler:?}); +if (_h == null) {{ + process.stderr.write('NYX_RESOLVER_NOT_FOUND: ' + {handler:?} + '\n'); + process.exit(78); +}} +(async () => {{ + try {{ + // Apollo resolver shape: (parent, args, context, info). + const _info = {{ fieldName: {field:?}, parentType: {type_name:?} }}; + const _result = await Promise.resolve(_h(null, {{ id: payload, input: payload }}, {{}}, _info)); + if (_result != null) process.stdout.write(String(_result) + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"#, + preamble = preamble, + handler = handler, + type_name = type_name, + field = field, + ); + HarnessSource { + source: body, + filename: "harness.js".to_owned(), + command: vec!["node".to_owned(), "harness.js".to_owned()], + extra_files: Vec::new(), + entry_subpath: Some(entry_subpath), + } +} + +fn emit_websocket_handler(spec: &HarnessSpec, path: &str, is_typescript: bool) -> HarnessSource { + let (preamble, entry_subpath) = nyx_js_preamble(spec, is_typescript); + let handler = &spec.entry_name; + let body = format!( + r#"{preamble} +// Phase 21 (Track M.3) — WebSocket handler. +process.stdout.write('__NYX_WEBSOCKET__: ' + {path:?} + '\n'); +const _h = _nyxResolve({handler:?}); +if (_h == null) {{ + process.stderr.write('NYX_HANDLER_NOT_FOUND: ' + {handler:?} + '\n'); + process.exit(78); +}} +(async () => {{ + try {{ + // ws library: handler(message); socket.io: handler(socket, data). + let _result; + try {{ + _result = await Promise.resolve(_h(payload)); + }} catch (e1) {{ + if (e1 && e1.constructor && e1.constructor.name === 'TypeError') {{ + _result = await Promise.resolve(_h({{ id: 'nyx-sock' }}, payload)); + }} else {{ + throw e1; + }} + }} + if (_result != null) process.stdout.write(String(_result) + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"#, + preamble = preamble, + handler = handler, + path = path, + ); + HarnessSource { + source: body, + filename: "harness.js".to_owned(), + command: vec!["node".to_owned(), "harness.js".to_owned()], + extra_files: Vec::new(), + entry_subpath: Some(entry_subpath), + } +} + +fn emit_middleware(spec: &HarnessSpec, name: &str, is_typescript: bool) -> HarnessSource { + let (preamble, entry_subpath) = nyx_js_preamble(spec, is_typescript); + let handler = &spec.entry_name; + let body = format!( + r#"{preamble} +// Phase 21 (Track M.3) — middleware. +process.stdout.write('__NYX_MIDDLEWARE__: ' + {name:?} + '\n'); +const _h = _nyxResolve({handler:?}); +if (_h == null) {{ + process.stderr.write('NYX_HANDLER_NOT_FOUND: ' + {handler:?} + '\n'); + process.exit(78); +}} +const _req = {{ body: payload, query: {{ q: payload }}, params: {{ id: payload }}, headers: {{}}, method: 'POST', url: '/nyx' }}; +const _res = {{ statusCode: 200, headers: {{}}, end: function(d){{ if (d != null) process.stdout.write(String(d) + '\n'); }}, setHeader: function(k, v){{ this.headers[k] = v; }} }}; +(async () => {{ + try {{ + const _result = await Promise.resolve(_h(_req, _res, function(_e){{ if (_e) process.stderr.write('NYX_NEXT_ERR: ' + _e + '\n'); }})); + if (_result != null) process.stdout.write(String(_result) + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"#, + preamble = preamble, + handler = handler, + name = name, + ); + HarnessSource { + source: body, + filename: "harness.js".to_owned(), + command: vec!["node".to_owned(), "harness.js".to_owned()], + extra_files: Vec::new(), + entry_subpath: Some(entry_subpath), + } +} + +fn emit_migration(spec: &HarnessSpec, version: Option<&str>, is_typescript: bool) -> HarnessSource { + let (preamble, entry_subpath) = nyx_js_preamble(spec, is_typescript); + let handler = &spec.entry_name; + let version_repr = version.unwrap_or(""); + let body = format!( + r#"{preamble} +// Phase 21 (Track M.3) — migration. +process.stdout.write('__NYX_MIGRATION__: ' + {version:?} + '\n'); +const _h = _nyxResolve({handler:?}); +if (_h == null) {{ + process.stderr.write('NYX_HANDLER_NOT_FOUND: ' + {handler:?} + '\n'); + process.exit(78); +}} +// Synthetic queryInterface for sequelize-style up/down(queryInterface, Sequelize). +const _qi = {{ + createTable: async function(){{}}, + addColumn: async function(){{}}, + dropTable: async function(){{}}, + removeColumn: async function(){{}}, + bulkInsert: async function(){{}}, + sequelize: {{ query: async function(){{}} }}, +}}; +const _prisma = {{ + $executeRaw: async function(){{}}, + $executeRawUnsafe: async function(s){{ if (s) process.stdout.write('NYX_PRISMA_SQL: ' + s + '\n'); }}, + $queryRaw: async function(){{}}, + $queryRawUnsafe: async function(){{}}, +}}; +(async () => {{ + try {{ + let _result; + // Try the sequelize shape first (queryInterface, Sequelize). + try {{ + _result = await Promise.resolve(_h(_qi, {{}})); + }} catch (e1) {{ + // Prisma / raw migration shape — pass payload. + try {{ + _result = await Promise.resolve(_h(payload)); + }} catch (e2) {{ + _result = await Promise.resolve(_h()); + }} + }} + if (_result != null) process.stdout.write(String(_result) + '\n'); + }} catch (e) {{ + process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n'); + }} +}})(); +"#, + preamble = preamble, + handler = handler, + version = version_repr, + ); + HarnessSource { + source: body, + filename: "harness.js".to_owned(), + command: vec!["node".to_owned(), "harness.js".to_owned()], + extra_files: Vec::new(), + entry_subpath: Some(entry_subpath), + } +} + /// Phase 04 — Track J.2 SSTI harness for Node (Handlebars). /// /// Reads `NYX_PAYLOAD`, simulates Handlebars's `{{helper a b}}` @@ -1827,7 +2110,7 @@ fn resolve_http_payload(slot: &PayloadSlot) -> (&'static str, String, &'static s } } -/// Supported entry kinds for both JS + TS after Phase 13. +/// Supported entry kinds for both JS + TS after Phase 21. pub const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::Function, EntryKindTag::HttpRoute, @@ -1835,6 +2118,11 @@ pub const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::LibraryApi, EntryKindTag::ClassMethod, EntryKindTag::MessageHandler, + EntryKindTag::ScheduledJob, + EntryKindTag::GraphQLResolver, + EntryKindTag::WebSocket, + EntryKindTag::Middleware, + EntryKindTag::Migration, ]; #[cfg(test)] diff --git a/src/dynamic/lang/mod.rs b/src/dynamic/lang/mod.rs index f8cf326a..3d285161 100644 --- a/src/dynamic/lang/mod.rs +++ b/src/dynamic/lang/mod.rs @@ -394,16 +394,65 @@ mod tests { assert_eq!(EntryKind::Unknown.tag(), T::Unknown); } - /// Phase 18 (Track M.0) baseline — the variants not yet wired by a - /// follow-up phase still route through the supported-set gate so the - /// verifier produces a structured `Inconclusive(EntryKindUnsupported)` - /// rather than degrading silently. Phase 19 lands `ClassMethod`; - /// Phase 20 lands `MessageHandler` on five langs (Python, Java, - /// JavaScript, TypeScript, Go); the rest stay unsupported. + /// Phase 21 (Track M.3) — the five remaining `EntryKind` variants + /// (`ScheduledJob` / `GraphQLResolver` / `WebSocket` / `Middleware` + /// / `Migration`) are now wired on the per-lang emitters the brief + /// targets. This regression guard pins the per-lang advertisement + /// matrix. Languages outside each variant's lang-set still route + /// through the supported-set gate so the verifier emits + /// `Inconclusive(EntryKindUnsupported)` rather than degrading + /// silently. #[test] - fn entry_kind_phase_21_variants_are_unsupported_everywhere() { + fn entry_kind_phase_21_variants_advertised_per_brief() { use crate::evidence::EntryKindTag as T; - let still_unsupported = [ + let want = |lang: Lang, tag: T| -> bool { + match (lang, tag) { + // ScheduledJob: cron (JS), quartz (Java), celery (Python), + // sidekiq (Ruby). TypeScript shares the JS emitter so it + // inherits the variant through the shared SUPPORTED slice. + ( + Lang::Python | Lang::JavaScript | Lang::TypeScript | Lang::Java | Lang::Ruby, + T::ScheduledJob, + ) => true, + // GraphQLResolver: apollo + relay (JS), graphene (Python), + // juniper (Rust), gqlgen (Go). TypeScript shares the JS + // emitter so it inherits resolver dispatch. + ( + Lang::Python + | Lang::JavaScript + | Lang::TypeScript + | Lang::Rust + | Lang::Go, + T::GraphQLResolver, + ) => true, + // WebSocket: socketio + channels (Python), ws (JS), + // actioncable (Ruby). + (Lang::Python | Lang::JavaScript | Lang::TypeScript | Lang::Ruby, T::WebSocket) => true, + // Middleware: express (JS), django (Python), rails (Ruby), + // spring (Java), laravel (PHP). + ( + Lang::Python + | Lang::JavaScript + | Lang::TypeScript + | Lang::Java + | Lang::Ruby + | Lang::Php, + T::Middleware, + ) => true, + // Migration: rails (Ruby), django + flask (Python), + // laravel (PHP), sequelize + prisma (JS). + ( + Lang::Python + | Lang::JavaScript + | Lang::TypeScript + | Lang::Ruby + | Lang::Php, + T::Migration, + ) => true, + _ => false, + } + }; + let phase_21_tags = [ T::ScheduledJob, T::GraphQLResolver, T::WebSocket, @@ -423,16 +472,20 @@ mod tests { Lang::Cpp, ] { let supported = entry_kinds_supported(lang); - for tag in still_unsupported { - assert!( - !supported.contains(&tag), - "{lang:?} prematurely advertised {tag:?} — Phase 21 has not landed the per-lang adapters for this variant" - ); - let hint = entry_kind_hint(lang, tag); - assert!( - hint.contains(tag.as_str()), - "{lang:?} hint must mention {tag:?}, got: {hint:?}" + for tag in phase_21_tags { + let expected = want(lang, tag); + let actual = supported.contains(&tag); + assert_eq!( + actual, expected, + "{lang:?} expected supported={expected:?} for {tag:?}; got supported={actual:?}", ); + if !actual { + let hint = entry_kind_hint(lang, tag); + assert!( + hint.contains(tag.as_str()), + "{lang:?} hint for unsupported {tag:?} must mention the attempted tag, got: {hint:?}" + ); + } } } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 1b452455..4d311a59 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -48,6 +48,8 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::HttpRoute, EntryKindTag::CliSubcommand, EntryKindTag::ClassMethod, + EntryKindTag::Middleware, + EntryKindTag::Migration, ]; impl LangEmitter for PhpEmitter { @@ -495,6 +497,16 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_class_method_harness(class, method)); } + // Phase 21 (Track M.3): Middleware short-circuit (Laravel handle()). + if let crate::evidence::EntryKind::Middleware { name } = &spec.entry_kind { + return Ok(emit_middleware_harness(&spec.entry_name, name)); + } + + // Phase 21 (Track M.3): Migration short-circuit (Laravel up()). + if let crate::evidence::EntryKind::Migration { version } = &spec.entry_kind { + return Ok(emit_migration_harness(&spec.entry_name, version.as_deref())); + } + let entry_source = read_entry_source(&spec.entry_file); let shape = PhpShape::detect(spec, &entry_source); let source = generate_source(spec, shape); @@ -1243,6 +1255,131 @@ try {{ } } +// ── Phase 21 (Track M.3) — synthetic entry-kind harnesses ───────────────────── + +fn nyx_php_preamble() -> String { + let shim = probe_shim(); + format!( + r#"getMessage() . "\n"); + exit(77); +}} + +echo "__NYX_SINK_HIT__\n"; +"#, + shim = shim, + ) +} + +fn emit_middleware_harness(handler: &str, name: &str) -> HarnessSource { + let preamble = nyx_php_preamble(); + let body = format!( + r#"{preamble} +echo "__NYX_MIDDLEWARE__: " . {name:?} . "\n"; + +$req = new stdClass(); +$req->body = $payload; +$req->path = '/nyx'; +$req->method = 'POST'; +$req->query = [ 'q' => $payload ]; +$next = function ($r) {{ return $r; }}; + +if (class_exists({handler:?})) {{ + $inst = new {handler}(); + if (method_exists($inst, 'handle')) {{ + try {{ + $result = $inst->handle($req, $next); + if ($result !== null) echo (string)$result . "\n"; + }} catch (Throwable $e) {{ + fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n"); + }} + }} else {{ + fwrite(STDERR, 'NYX_METHOD_NOT_FOUND: handle' . "\n"); + exit(78); + }} +}} elseif (function_exists({handler:?})) {{ + try {{ + $result = call_user_func({handler:?}, $req, $next); + if ($result !== null) echo (string)$result . "\n"; + }} catch (Throwable $e) {{ + fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n"); + }} +}} else {{ + fwrite(STDERR, 'NYX_HANDLER_NOT_FOUND: ' . {handler:?} . "\n"); + exit(78); +}} +"#, + preamble = preamble, + handler = handler, + name = name, + ); + HarnessSource { + source: body, + filename: "harness.php".to_owned(), + command: vec!["php".to_owned(), "harness.php".to_owned()], + extra_files: vec![], + entry_subpath: Some("entry.php".to_owned()), + } +} + +fn emit_migration_harness(handler: &str, version: Option<&str>) -> HarnessSource { + let preamble = nyx_php_preamble(); + let version_repr = version.unwrap_or(""); + let body = format!( + r#"{preamble} +echo "__NYX_MIGRATION__: " . {version:?} . "\n"; + +if (class_exists({handler:?})) {{ + $inst = new {handler}(); + if (method_exists($inst, 'up')) {{ + try {{ + $result = $inst->up(); + if ($result !== null) echo (string)$result . "\n"; + }} catch (Throwable $e) {{ + fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n"); + }} + }} else {{ + fwrite(STDERR, 'NYX_METHOD_NOT_FOUND: up' . "\n"); + exit(78); + }} +}} elseif (function_exists({handler:?})) {{ + try {{ + $result = call_user_func({handler:?}); + if ($result !== null) echo (string)$result . "\n"; + }} catch (Throwable $e) {{ + fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n"); + }} +}} else {{ + fwrite(STDERR, 'NYX_HANDLER_NOT_FOUND: ' . {handler:?} . "\n"); + exit(78); +}} +"#, + preamble = preamble, + handler = handler, + version = version_repr, + ); + HarnessSource { + source: body, + filename: "harness.php".to_owned(), + command: vec!["php".to_owned(), "harness.php".to_owned()], + extra_files: vec![], + entry_subpath: Some("entry.php".to_owned()), + } +} + fn build_call_expr(spec: &HarnessSpec, shape: PhpShape, func: &str) -> String { match shape { PhpShape::TopLevelScript => "null".to_owned(), diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index d729050a..0942f21a 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -47,6 +47,11 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::CliSubcommand, EntryKindTag::ClassMethod, EntryKindTag::MessageHandler, + EntryKindTag::ScheduledJob, + EntryKindTag::GraphQLResolver, + EntryKindTag::WebSocket, + EntryKindTag::Middleware, + EntryKindTag::Migration, ]; impl LangEmitter for PythonEmitter { @@ -704,6 +709,41 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_message_handler(spec, queue)); } + // Phase 21 (Track M.3): ScheduledJob short-circuit. Synthetic + // harness — imports the entry module, invokes the named handler + // with the payload as the single positional argument (matching + // Celery's `task(arg)` shape), then prints the sink-hit sentinel. + if let crate::evidence::EntryKind::ScheduledJob { schedule } = &spec.entry_kind { + return Ok(emit_scheduled_job(spec, schedule.as_deref())); + } + + // Phase 21 (Track M.3): GraphQLResolver short-circuit. Synthetic + // resolver dispatch — `resolve_(self, info, payload)`. + if let crate::evidence::EntryKind::GraphQLResolver { type_name, field } = &spec.entry_kind { + return Ok(emit_graphql_resolver(spec, type_name, field)); + } + + // Phase 21 (Track M.3): WebSocket short-circuit. Invokes the + // handler with `(self, payload)` shape that python-socketio / + // Django Channels both accept. + if let crate::evidence::EntryKind::WebSocket { path } = &spec.entry_kind { + return Ok(emit_websocket_handler(spec, path)); + } + + // Phase 21 (Track M.3): Middleware short-circuit. Builds a + // synthetic `request` object whose body field carries the payload + // and invokes the middleware with `(request, lambda r: r)` next. + if let crate::evidence::EntryKind::Middleware { name } = &spec.entry_kind { + return Ok(emit_middleware(spec, name)); + } + + // Phase 21 (Track M.3): Migration short-circuit. Invokes the + // module-level `upgrade()` / `up()` function (no args) so the + // migration's SQL / DDL emitter runs. + if let crate::evidence::EntryKind::Migration { version } = &spec.entry_kind { + return Ok(emit_migration(spec, version.as_deref())); + } + let entry_source = read_entry_source(&spec.entry_file); let shape = PythonShape::detect(spec, &entry_source); let body = generate_for_shape(spec, shape); @@ -934,6 +974,257 @@ except Exception as _e: } } +// ── Phase 21 (Track M.3) — synthetic entry-kind harnesses ───────────────────── + +/// Phase 21: ScheduledJob harness. Imports the entry module, locates +/// the named function, invokes it with the payload string as the +/// single positional argument, and prints the sink-hit sentinel. +fn emit_scheduled_job(spec: &HarnessSpec, schedule: Option<&str>) -> HarnessSource { + let preamble = harness_preamble(spec); + let postamble = harness_postamble(); + let handler = &spec.entry_name; + let schedule_repr = schedule.unwrap_or(""); + let body = format!( + r#"# Shape: scheduled job — Phase 21 / Track M.3. +print("__NYX_SCHEDULED_JOB__: " + {schedule:?}, flush=True) +_h = getattr(_entry_mod, {handler:?}, None) +if _h is None: + print("NYX_HANDLER_NOT_FOUND: " + {handler:?}, file=sys.stderr, flush=True) + sys.exit(78) +try: + _result = _h(payload) + if _result is not None: + try: + print(str(_result), flush=True) + except Exception: + pass +except SystemExit as _e: + sys.exit(_e.code) +except Exception as _e: + print(f"NYX_EXCEPTION: {{type(_e).__name__}}: {{_e}}", file=sys.stderr, flush=True) +"#, + handler = handler, + schedule = schedule_repr, + ); + HarnessSource { + source: format!("{preamble}\n{body}\n{postamble}"), + filename: "harness.py".to_owned(), + command: vec!["python3".to_owned(), "harness.py".to_owned()], + extra_files: vec![], + entry_subpath: None, + } +} + +/// Phase 21: GraphQLResolver harness. Imports the entry module, +/// locates the named resolver function, builds a synthetic `info` +/// context object, and invokes the resolver with `(info, payload)`. +fn emit_graphql_resolver(spec: &HarnessSpec, type_name: &str, field: &str) -> HarnessSource { + let preamble = harness_preamble(spec); + let postamble = harness_postamble(); + let handler = &spec.entry_name; + let body = format!( + r#"# Shape: GraphQL resolver — Phase 21 / Track M.3. +print("__NYX_GRAPHQL_RESOLVER__: " + {type_name:?} + "." + {field:?}, flush=True) + +class _NyxGraphQLInfo: + """Synthetic resolver context — apollo-style {{ context, fieldName }}.""" + def __init__(self, field_name): + self.field_name = field_name + self.context = {{}} + +_resolver = getattr(_entry_mod, {handler:?}, None) +if _resolver is None: + print("NYX_RESOLVER_NOT_FOUND: " + {handler:?}, file=sys.stderr, flush=True) + sys.exit(78) +try: + # Graphene resolvers are `resolve_field(self, info, **args)`; we + # synthesise `self = None`, `info = _NyxGraphQLInfo`, and pass the + # payload positionally so a `def resolve_foo(self, info, id):` shape + # binds `id = payload`. + _result = _resolver(None, _NyxGraphQLInfo({field:?}), payload) + if _result is not None: + try: + print(str(_result), flush=True) + except Exception: + pass +except SystemExit as _e: + sys.exit(_e.code) +except TypeError: + # Fallback for free-function resolvers without the `self` formal. + try: + _result = _resolver(_NyxGraphQLInfo({field:?}), payload) + if _result is not None: + print(str(_result), flush=True) + except Exception as _e: + print(f"NYX_EXCEPTION: {{type(_e).__name__}}: {{_e}}", file=sys.stderr, flush=True) +except Exception as _e: + print(f"NYX_EXCEPTION: {{type(_e).__name__}}: {{_e}}", file=sys.stderr, flush=True) +"#, + type_name = type_name, + field = field, + handler = handler, + ); + HarnessSource { + source: format!("{preamble}\n{body}\n{postamble}"), + filename: "harness.py".to_owned(), + command: vec!["python3".to_owned(), "harness.py".to_owned()], + extra_files: vec![], + entry_subpath: None, + } +} + +/// Phase 21: WebSocket handler harness. Imports the entry module, +/// locates the handler (`receive` / `on_` / free function), +/// and invokes it with the payload as the single message frame. +fn emit_websocket_handler(spec: &HarnessSpec, path: &str) -> HarnessSource { + let preamble = harness_preamble(spec); + let postamble = harness_postamble(); + let handler = &spec.entry_name; + let body = format!( + r#"# Shape: WebSocket handler — Phase 21 / Track M.3. +print("__NYX_WEBSOCKET__: " + {path:?}, flush=True) +_h = getattr(_entry_mod, {handler:?}, None) +if _h is None: + print("NYX_HANDLER_NOT_FOUND: " + {handler:?}, file=sys.stderr, flush=True) + sys.exit(78) +try: + # python-socketio handlers are `def message(sid, data)`; Channels + # consumers are `def receive(self, text_data=None, bytes_data=None)`. + # Try (sid, payload) first, then fall back to (payload). + try: + _result = _h("nyx-sid", payload) + except TypeError: + try: + _result = _h(payload) + except TypeError: + _result = _h(None, payload) + if _result is not None: + try: + print(str(_result), flush=True) + except Exception: + pass +except SystemExit as _e: + sys.exit(_e.code) +except Exception as _e: + print(f"NYX_EXCEPTION: {{type(_e).__name__}}: {{_e}}", file=sys.stderr, flush=True) +"#, + path = path, + handler = handler, + ); + HarnessSource { + source: format!("{preamble}\n{body}\n{postamble}"), + filename: "harness.py".to_owned(), + command: vec!["python3".to_owned(), "harness.py".to_owned()], + extra_files: vec![], + entry_subpath: None, + } +} + +/// Phase 21: Middleware harness. Builds a synthetic request object +/// whose body carries the payload, invokes the middleware with a +/// pass-through `next` callable. +fn emit_middleware(spec: &HarnessSpec, name: &str) -> HarnessSource { + let preamble = harness_preamble(spec); + let postamble = harness_postamble(); + let handler = &spec.entry_name; + let body = format!( + r#"# Shape: middleware — Phase 21 / Track M.3. +print("__NYX_MIDDLEWARE__: " + {name:?}, flush=True) + +class _NyxRequest: + """Synthetic Django / Flask-ish request carrying the payload.""" + def __init__(self, body): + self.body = body + self.path = "/nyx" + self.method = "POST" + self.META = {{}} + self.GET = {{"q": body}} + self.POST = {{"q": body}} + +_h = getattr(_entry_mod, {handler:?}, None) +if _h is None: + print("NYX_HANDLER_NOT_FOUND: " + {handler:?}, file=sys.stderr, flush=True) + sys.exit(78) +try: + _req = _NyxRequest(payload) + # Try class-shaped middleware (instantiate with a get_response stub). + try: + _mw = _h(lambda r: r) + _result = _mw(_req) + except TypeError: + # Method on an existing class instance. + _result = _h(_req) + if _result is not None: + try: + print(str(_result), flush=True) + except Exception: + pass +except SystemExit as _e: + sys.exit(_e.code) +except Exception as _e: + print(f"NYX_EXCEPTION: {{type(_e).__name__}}: {{_e}}", file=sys.stderr, flush=True) +"#, + name = name, + handler = handler, + ); + HarnessSource { + source: format!("{preamble}\n{body}\n{postamble}"), + filename: "harness.py".to_owned(), + command: vec!["python3".to_owned(), "harness.py".to_owned()], + extra_files: vec![], + entry_subpath: None, + } +} + +/// Phase 21: Migration harness. Invokes the module-level `upgrade()` +/// / `up()` function and prints the version sentinel. +fn emit_migration(spec: &HarnessSpec, version: Option<&str>) -> HarnessSource { + let preamble = harness_preamble(spec); + let postamble = harness_postamble(); + let handler = &spec.entry_name; + let version_repr = version.unwrap_or(""); + let body = format!( + r#"# Shape: migration — Phase 21 / Track M.3. +print("__NYX_MIGRATION__: " + {version:?}, flush=True) +_h = getattr(_entry_mod, {handler:?}, None) +if _h is None: + print("NYX_HANDLER_NOT_FOUND: " + {handler:?}, file=sys.stderr, flush=True) + sys.exit(78) +try: + # Migrations conventionally take no arguments; pass payload if the + # function declares positional params (best-effort introspection). + import inspect + sig = None + try: + sig = inspect.signature(_h) + except (TypeError, ValueError): + sig = None + if sig is not None and len(sig.parameters) >= 1: + _result = _h(payload) + else: + _result = _h() + if _result is not None: + try: + print(str(_result), flush=True) + except Exception: + pass +except SystemExit as _e: + sys.exit(_e.code) +except Exception as _e: + print(f"NYX_EXCEPTION: {{type(_e).__name__}}: {{_e}}", file=sys.stderr, flush=True) +"#, + version = version_repr, + handler = handler, + ); + HarnessSource { + source: format!("{preamble}\n{body}\n{postamble}"), + filename: "harness.py".to_owned(), + command: vec!["python3".to_owned(), "harness.py".to_owned()], + extra_files: vec![], + entry_subpath: None, + } +} + #[derive(Debug, Clone, Copy)] enum PythonBroker { Kafka, diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 26996337..f9a2d2ad 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -45,6 +45,10 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::HttpRoute, EntryKindTag::CliSubcommand, EntryKindTag::ClassMethod, + EntryKindTag::ScheduledJob, + EntryKindTag::WebSocket, + EntryKindTag::Middleware, + EntryKindTag::Migration, ]; impl LangEmitter for RubyEmitter { @@ -437,6 +441,26 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_class_method_harness(class, method)); } + // Phase 21 (Track M.3): ScheduledJob short-circuit (Sidekiq workers). + if let crate::evidence::EntryKind::ScheduledJob { schedule } = &spec.entry_kind { + return Ok(emit_scheduled_job_harness(&spec.entry_name, schedule.as_deref())); + } + + // Phase 21 (Track M.3): WebSocket short-circuit (ActionCable channels). + if let crate::evidence::EntryKind::WebSocket { path } = &spec.entry_kind { + return Ok(emit_websocket_handler_harness(&spec.entry_name, path)); + } + + // Phase 21 (Track M.3): Middleware short-circuit (Rack-shape). + if let crate::evidence::EntryKind::Middleware { name } = &spec.entry_kind { + return Ok(emit_middleware_harness(&spec.entry_name, name)); + } + + // Phase 21 (Track M.3): Migration short-circuit (ActiveRecord up/down). + if let crate::evidence::EntryKind::Migration { version } = &spec.entry_kind { + return Ok(emit_migration_harness(&spec.entry_name, version.as_deref())); + } + let entry_source = read_entry_source(&spec.entry_file); let shape = RubyShape::detect(spec, &entry_source); let source = generate_source(spec, shape); @@ -554,6 +578,253 @@ end } } +// ── Phase 21 (Track M.3) — synthetic entry-kind harnesses ───────────────────── + +fn nyx_ruby_preamble() -> String { + let shim = probe_shim(); + format!( + r#"# Nyx dynamic harness — Phase 21 / Track M.3 (auto-generated). +{shim} + +def nyx_payload + v = ENV['NYX_PAYLOAD'] + return v if v && !v.empty? + b64 = ENV['NYX_PAYLOAD_B64'] + if b64 && !b64.empty? + begin + require 'base64' + return Base64.decode64(b64) + rescue StandardError + return '' + end + end + '' +end + +$nyx_payload = nyx_payload + +begin + require_relative './entry' +rescue LoadError, ScriptError => e + STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}") + exit 77 +end + +puts "__NYX_SINK_HIT__" +"#, + shim = shim, + ) +} + +fn emit_scheduled_job_harness(handler: &str, schedule: Option<&str>) -> HarnessSource { + let preamble = nyx_ruby_preamble(); + let sched = schedule.unwrap_or(""); + let body = format!( + r#"{preamble} +puts "__NYX_SCHEDULED_JOB__: " + {sched:?} + +# Sidekiq workers expose perform(*args) on a class. Try looking up the +# named class first; fall back to a top-level function. +target = nil +if Object.const_defined?({handler:?}) + begin + target = Object.const_get({handler:?}).new + if target.respond_to?(:perform) + begin + result = target.perform($nyx_payload) + print(result.to_s) if result + rescue StandardError => e + STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") + end + exit 0 + end + rescue StandardError + end +end + +if respond_to?({handler:?}.to_sym, true) + begin + result = send({handler:?}.to_sym, $nyx_payload) + print(result.to_s) if result + rescue StandardError => e + STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") + end +else + STDERR.puts("NYX_HANDLER_NOT_FOUND: " + {handler:?}) + exit 78 +end +"#, + preamble = preamble, + handler = handler, + sched = sched, + ); + HarnessSource { + source: body, + filename: "harness.rb".to_owned(), + command: vec!["ruby".to_owned(), "harness.rb".to_owned()], + extra_files: vec![], + entry_subpath: Some("entry.rb".to_owned()), + } +} + +fn emit_websocket_handler_harness(handler: &str, path: &str) -> HarnessSource { + let preamble = nyx_ruby_preamble(); + let body = format!( + r#"{preamble} +puts "__NYX_WEBSOCKET__: " + {path:?} + +# ActionCable channels expose `receive(data)` on a subclass. Find the +# enclosing class via const lookup; fall back to top-level send. +if Object.const_defined?({handler:?}) + cls = Object.const_get({handler:?}) + begin + inst = cls.new rescue (cls.allocate rescue nil) + if inst && inst.respond_to?(:receive) + begin + result = inst.receive($nyx_payload) + print(result.to_s) if result + rescue StandardError => e + STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") + end + exit 0 + end + rescue StandardError + end +end + +if respond_to?({handler:?}.to_sym, true) + begin + result = send({handler:?}.to_sym, $nyx_payload) + print(result.to_s) if result + rescue StandardError => e + STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") + end +else + STDERR.puts("NYX_HANDLER_NOT_FOUND: " + {handler:?}) + exit 78 +end +"#, + preamble = preamble, + handler = handler, + path = path, + ); + HarnessSource { + source: body, + filename: "harness.rb".to_owned(), + command: vec!["ruby".to_owned(), "harness.rb".to_owned()], + extra_files: vec![], + entry_subpath: Some("entry.rb".to_owned()), + } +} + +fn emit_middleware_harness(handler: &str, name: &str) -> HarnessSource { + let preamble = nyx_ruby_preamble(); + let body = format!( + r#"{preamble} +puts "__NYX_MIDDLEWARE__: " + {name:?} + +# Rack-shape middleware: class with #call(env). +env = {{ + 'REQUEST_METHOD' => 'POST', + 'PATH_INFO' => '/nyx', + 'QUERY_STRING' => "q=#{{$nyx_payload}}", + 'rack.input' => StringIO.new($nyx_payload), + 'nyx.payload' => $nyx_payload, +}} +require 'stringio' + +if Object.const_defined?({handler:?}) + cls = Object.const_get({handler:?}) + begin + inst = cls.new(lambda {{ |e| [200, {{}}, ['ok']] }}) + if inst.respond_to?(:call) + result = inst.call(env) + print(result.to_s) if result + exit 0 + end + rescue StandardError => e + STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") + end +end + +if respond_to?({handler:?}.to_sym, true) + begin + result = send({handler:?}.to_sym, env) + print(result.to_s) if result + rescue StandardError => e + STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") + end +else + STDERR.puts("NYX_HANDLER_NOT_FOUND: " + {handler:?}) + exit 78 +end +"#, + preamble = preamble, + handler = handler, + name = name, + ); + HarnessSource { + source: body, + filename: "harness.rb".to_owned(), + command: vec!["ruby".to_owned(), "harness.rb".to_owned()], + extra_files: vec![], + entry_subpath: Some("entry.rb".to_owned()), + } +} + +fn emit_migration_harness(handler: &str, version: Option<&str>) -> HarnessSource { + let preamble = nyx_ruby_preamble(); + let ver = version.unwrap_or(""); + let body = format!( + r#"{preamble} +puts "__NYX_MIGRATION__: " + {ver:?} + +# ActiveRecord migrations expose `up` / `down` / `change` on a subclass. +if Object.const_defined?({handler:?}) + cls = Object.const_get({handler:?}) + begin + inst = cls.new + %i[up change down].each do |m| + if inst.respond_to?(m) + begin + result = inst.send(m) + print(result.to_s) if result + rescue StandardError => e + STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") + end + exit 0 + end + end + rescue StandardError => e + STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") + end +end + +if respond_to?({handler:?}.to_sym, true) + begin + result = send({handler:?}.to_sym) + print(result.to_s) if result + rescue StandardError => e + STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") + end +else + STDERR.puts("NYX_HANDLER_NOT_FOUND: " + {handler:?}) + exit 78 +end +"#, + preamble = preamble, + handler = handler, + ver = ver, + ); + HarnessSource { + source: body, + filename: "harness.rb".to_owned(), + command: vec!["ruby".to_owned(), "harness.rb".to_owned()], + extra_files: vec![], + entry_subpath: Some("entry.rb".to_owned()), + } +} + /// Phase 03 — Track J.1 deserialize harness for Ruby. /// /// Wraps a call to `Marshal.load(input)` with a const-lookup diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index ed0c9c8f..fc577604 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -44,6 +44,7 @@ const SUPPORTED: &[EntryKindTag] = &[ EntryKindTag::CliSubcommand, EntryKindTag::LibraryApi, EntryKindTag::ClassMethod, + EntryKindTag::GraphQLResolver, ]; impl LangEmitter for RustEmitter { @@ -829,6 +830,13 @@ pub fn emit(spec: &HarnessSpec) -> Result { return Ok(emit_class_method_harness(spec, class, method)); } + // Phase 21 (Track M.3): GraphQLResolver short-circuit (Juniper). + // Emits a `src/main.rs` that invokes `entry::(payload)` + // directly — Juniper resolvers are plain async fns in the source. + if let crate::evidence::EntryKind::GraphQLResolver { type_name, field } = &spec.entry_kind { + return Ok(emit_graphql_resolver_harness(spec, type_name, field)); + } + let shape = detect_shape(spec); // Generic + LibfuzzerTarget accept Param(0)/EnvVar; richer shapes @@ -948,6 +956,92 @@ fn b64_decode(input: &[u8]) -> Option> {{ } } +// ── Phase 21 (Track M.3) — synthetic entry-kind harnesses ───────────────────── + +/// Phase 21 (Track M.3) — GraphQL resolver harness for Rust (Juniper). +/// +/// Emits a `src/main.rs` that invokes `entry::(&payload)` — +/// the harness assumes the entry module exposes a free function with +/// the resolver name; Juniper's `#[graphql_object]` impl methods are +/// not directly reachable through `mod entry`, so the v1 path goes +/// through a thin re-export the entry file is expected to publish. +fn emit_graphql_resolver_harness( + spec: &HarnessSpec, + type_name: &str, + field: &str, +) -> HarnessSource { + let shim = probe_shim(); + let cargo_toml = generate_cargo_toml(spec.expected_cap); + let handler = &spec.entry_name; + let label = format!("{type_name}.{field}"); + let body = format!( + r#"//! Nyx dynamic harness — GraphQL resolver (Phase 21 / Track M.3). +mod entry; +{shim} +fn main() {{ + let payload = nyx_payload(); + __nyx_install_crash_guard("{label}"); + println!("__NYX_GRAPHQL_RESOLVER__: {type_name}.{field}"); + println!("__NYX_SINK_HIT__"); + let _ = entry::{handler}(&payload); +}} + +fn nyx_payload() -> String {{ + if let Ok(v) = std::env::var("NYX_PAYLOAD") {{ + if !v.is_empty() {{ + return v; + }} + }} + if let Ok(b64) = std::env::var("NYX_PAYLOAD_B64") {{ + if let Some(bytes) = b64_decode(b64.as_bytes()) {{ + return String::from_utf8_lossy(&bytes).into_owned(); + }} + }} + String::new() +}} + +fn b64_decode(input: &[u8]) -> Option> {{ + const TABLE: [u8; 128] = {{ + let mut t = [255u8; 128]; + let alphabet: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut i = 0usize; + while i < alphabet.len() {{ + t[alphabet[i] as usize] = i as u8; + i += 1; + }} + t + }}; + let input: Vec = input.iter().copied().filter(|&c| c != b'\n' && c != b'\r').collect(); + let mut out = Vec::with_capacity(input.len() * 3 / 4); + let mut i = 0; + while i + 3 < input.len() {{ + let a = *TABLE.get(input[i] as usize)? as u32; + let b = *TABLE.get(input[i + 1] as usize)? as u32; + let c = if input[i + 2] == b'=' {{ 64 }} else {{ *TABLE.get(input[i + 2] as usize)? as u32 }}; + let d = if input[i + 3] == b'=' {{ 64 }} else {{ *TABLE.get(input[i + 3] as usize)? as u32 }}; + if a == 255 || b == 255 || c == 255 || d == 255 {{ return None; }} + out.push(((a << 2) | (b >> 4)) as u8); + if input[i + 2] != b'=' {{ out.push(((b << 4) | (c >> 2)) as u8); }} + if input[i + 3] != b'=' {{ out.push(((c << 6) | d) as u8); }} + i += 4; + }} + Some(out) +}} +"#, + handler = handler, + type_name = type_name, + field = field, + label = label, + ); + HarnessSource { + source: body, + filename: "src/main.rs".into(), + command: vec!["target/release/nyx_harness".into()], + extra_files: vec![("Cargo.toml".into(), cargo_toml)], + entry_subpath: Some("src/entry.rs".into()), + } +} + /// True when the entry source declares `class` as a type that derives /// or implements `Default`. Two byte-level patterns are recognised: /// diff --git a/tests/dynamic_fixtures/graphql_resolver/apollo/benign.js b/tests/dynamic_fixtures/graphql_resolver/apollo/benign.js new file mode 100644 index 00000000..738bae6d --- /dev/null +++ b/tests/dynamic_fixtures/graphql_resolver/apollo/benign.js @@ -0,0 +1,9 @@ +// Phase 21 — Apollo resolver benign control. +const _NYX_ADAPTER_MARKER = "require('@apollo/server')"; + +function resolveUser(parent, args, ctx) { + const id = String(args.id || '').replace(/[^A-Za-z0-9_-]/g, ''); + return { id, name: 'user-' + id }; +} + +module.exports = { resolveUser }; diff --git a/tests/dynamic_fixtures/graphql_resolver/apollo/vuln.js b/tests/dynamic_fixtures/graphql_resolver/apollo/vuln.js new file mode 100644 index 00000000..1ffa0254 --- /dev/null +++ b/tests/dynamic_fixtures/graphql_resolver/apollo/vuln.js @@ -0,0 +1,14 @@ +// Phase 21 (Track M.3) — Apollo GraphQL resolver vuln fixture. +// +// `resolveUser(parent, args)` is a resolver from an Apollo schema that +// splices `args.id` into a SQL query via raw string concatenation — +// classic GraphQL → SQLi shape. +const _NYX_ADAPTER_MARKER = "require('@apollo/server')"; + +function resolveUser(parent, args, ctx) { + // SINK: tainted args.id concatenated into SQL. + const query = "SELECT * FROM users WHERE id = '" + args.id + "'"; + return { id: args.id, name: 'user-' + args.id, _query: query }; +} + +module.exports = { resolveUser }; diff --git a/tests/dynamic_fixtures/graphql_resolver/gqlgen/benign.go b/tests/dynamic_fixtures/graphql_resolver/gqlgen/benign.go new file mode 100644 index 00000000..42be2613 --- /dev/null +++ b/tests/dynamic_fixtures/graphql_resolver/gqlgen/benign.go @@ -0,0 +1,15 @@ +// Phase 21 — gqlgen benign control. +package benign + +// import "github.com/99designs/gqlgen/graphql" + +import "regexp" + +var idAllow = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +func ResolveUser(id string) (string, error) { + if !idAllow.MatchString(id) { + return "", nil + } + return "user-" + id, nil +} diff --git a/tests/dynamic_fixtures/graphql_resolver/gqlgen/vuln.go b/tests/dynamic_fixtures/graphql_resolver/gqlgen/vuln.go new file mode 100644 index 00000000..466d9cf1 --- /dev/null +++ b/tests/dynamic_fixtures/graphql_resolver/gqlgen/vuln.go @@ -0,0 +1,23 @@ +// Phase 21 (Track M.3) — gqlgen GraphQL resolver vuln fixture. +// +// `resolveUser(ctx, id)` is a gqlgen resolver (substring marker only — +// the real gqlgen runtime is not on the workdir's go.mod). The +// resolver splices the id into a shell command via os/exec. +package vuln + +// import "github.com/99designs/gqlgen/graphql" + +import ( + "os/exec" +) + +// type queryResolver struct{} + +func ResolveUser(id string) (string, error) { + // SINK: tainted id concatenated into shell command. + out, err := exec.Command("/bin/sh", "-c", "echo lookup-"+id).Output() + if err != nil { + return "", err + } + return string(out), nil +} diff --git a/tests/dynamic_fixtures/graphql_resolver/graphene/benign.py b/tests/dynamic_fixtures/graphql_resolver/graphene/benign.py new file mode 100644 index 00000000..6ae18132 --- /dev/null +++ b/tests/dynamic_fixtures/graphql_resolver/graphene/benign.py @@ -0,0 +1,9 @@ +"""Phase 21 — Graphene resolver benign control.""" +import re + +_NYX_ADAPTER_MARKER = "import graphene" + + +def resolve_user(self, info, id): + safe = re.sub(r"[^A-Za-z0-9_-]", "", str(id)) + return "user-" + safe diff --git a/tests/dynamic_fixtures/graphql_resolver/graphene/vuln.py b/tests/dynamic_fixtures/graphql_resolver/graphene/vuln.py new file mode 100644 index 00000000..0d9634e7 --- /dev/null +++ b/tests/dynamic_fixtures/graphql_resolver/graphene/vuln.py @@ -0,0 +1,15 @@ +"""Phase 21 (Track M.3) — Graphene resolver vuln fixture. + +`resolve_user(self, info, id)` is a Graphene query resolver that +splices the tainted `id` into a shell command via `os.system`. +""" +import os + +_NYX_ADAPTER_MARKER = "import graphene" +_NYX_OBJECT_TYPE_MARKER = "class Query(graphene.ObjectType):" + + +def resolve_user(self, info, id): + # SINK: tainted id concatenated into shell command. + os.system("echo lookup-" + str(id)) + return "user-" + str(id) diff --git a/tests/dynamic_fixtures/graphql_resolver/juniper/benign.rs b/tests/dynamic_fixtures/graphql_resolver/juniper/benign.rs new file mode 100644 index 00000000..c79945b4 --- /dev/null +++ b/tests/dynamic_fixtures/graphql_resolver/juniper/benign.rs @@ -0,0 +1,10 @@ +//! Phase 21 — Juniper resolver benign control. +// use juniper::graphql_object; + +pub fn resolve_user(id: &str) -> String { + let safe: String = id + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') + .collect(); + format!("user-{}", safe) +} diff --git a/tests/dynamic_fixtures/graphql_resolver/juniper/vuln.rs b/tests/dynamic_fixtures/graphql_resolver/juniper/vuln.rs new file mode 100644 index 00000000..3fe64bdf --- /dev/null +++ b/tests/dynamic_fixtures/graphql_resolver/juniper/vuln.rs @@ -0,0 +1,15 @@ +//! Phase 21 (Track M.3) — Juniper GraphQL resolver vuln fixture. +//! +//! `resolve_user(id)` is a Juniper resolver (substring marker only — +//! the real `juniper` crate is not on the workdir's Cargo.toml). The +//! resolver builds a SQL query via raw string concat — classic +//! GraphQL → SQLi shape. + +// use juniper::graphql_object; + +pub fn resolve_user(id: &str) -> String { + // SINK: tainted id concatenated into SQL. + let query = format!("SELECT * FROM users WHERE id = '{}'", id); + let _ = query; + format!("user-{}", id) +} diff --git a/tests/dynamic_fixtures/graphql_resolver/relay/benign.js b/tests/dynamic_fixtures/graphql_resolver/relay/benign.js new file mode 100644 index 00000000..4b49d659 --- /dev/null +++ b/tests/dynamic_fixtures/graphql_resolver/relay/benign.js @@ -0,0 +1,9 @@ +// Phase 21 — graphql-relay benign control. +const _NYX_ADAPTER_MARKER = "require('graphql-relay')"; + +function resolveNode(parent, args) { + const id = String(args.id || '').replace(/[^A-Za-z0-9_-]/g, ''); + return { id }; +} + +module.exports = { resolveNode }; diff --git a/tests/dynamic_fixtures/graphql_resolver/relay/vuln.js b/tests/dynamic_fixtures/graphql_resolver/relay/vuln.js new file mode 100644 index 00000000..0afd37cd --- /dev/null +++ b/tests/dynamic_fixtures/graphql_resolver/relay/vuln.js @@ -0,0 +1,10 @@ +// Phase 21 (Track M.3) — graphql-relay vuln fixture. +const _NYX_ADAPTER_MARKER = "require('graphql-relay')"; + +function resolveNode(parent, args, ctx, info) { + // SINK: tainted globalId interpolated into SQL. + const sql = "SELECT * FROM nodes WHERE id = '" + args.id + "'"; + return { id: args.id, _sql: sql }; +} + +module.exports = { resolveNode }; diff --git a/tests/dynamic_fixtures/middleware/django/benign.py b/tests/dynamic_fixtures/middleware/django/benign.py new file mode 100644 index 00000000..461a8f64 --- /dev/null +++ b/tests/dynamic_fixtures/middleware/django/benign.py @@ -0,0 +1,18 @@ +"""Phase 21 — Django middleware benign control.""" +import os +import shlex + +_NYX_ADAPTER_MARKER = "from django.utils.deprecation import MiddlewareMixin" + + +class AuditMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + os.system("echo " + shlex.quote(str(request.body))) + return self.get_response(request) + + +def audit(get_response): + return AuditMiddleware(get_response) diff --git a/tests/dynamic_fixtures/middleware/django/vuln.py b/tests/dynamic_fixtures/middleware/django/vuln.py new file mode 100644 index 00000000..d4581948 --- /dev/null +++ b/tests/dynamic_fixtures/middleware/django/vuln.py @@ -0,0 +1,23 @@ +"""Phase 21 (Track M.3) — Django middleware vuln fixture. + +`AuditMiddleware.__call__(request)` splices `request.body` into a shell +command via `os.system`. +""" +import os + +_NYX_ADAPTER_MARKER = "from django.utils.deprecation import MiddlewareMixin" + + +class AuditMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # SINK: tainted request body concatenated into shell command. + os.system("echo " + str(request.body)) + return self.get_response(request) + + +# Module-level alias for the harness to resolve `audit` directly. +def audit(get_response): + return AuditMiddleware(get_response) diff --git a/tests/dynamic_fixtures/middleware/express/benign.js b/tests/dynamic_fixtures/middleware/express/benign.js new file mode 100644 index 00000000..bca1dd65 --- /dev/null +++ b/tests/dynamic_fixtures/middleware/express/benign.js @@ -0,0 +1,11 @@ +// Phase 21 — Express middleware benign control. +const _NYX_ADAPTER_MARKER = "require('express')"; + +function audit(req, res, next) { + const body = String(req.body || ''); + if (body.length > 1024) return res.end('too large'); + if (typeof next === 'function') next(); + return 'ok'; +} + +module.exports = { audit }; diff --git a/tests/dynamic_fixtures/middleware/express/vuln.js b/tests/dynamic_fixtures/middleware/express/vuln.js new file mode 100644 index 00000000..00036947 --- /dev/null +++ b/tests/dynamic_fixtures/middleware/express/vuln.js @@ -0,0 +1,17 @@ +// Phase 21 (Track M.3) — Express middleware vuln fixture. +// +// `audit(req, res, next)` is mounted via `app.use(audit)`. It splices +// the request body into a shell command via `execSync`. +const _NYX_ADAPTER_MARKER = "require('express')"; +const _NYX_REGISTER_MARKER = "app.use(audit)"; + +const { execSync } = require('child_process'); + +function audit(req, res, next) { + // SINK: tainted req.body concatenated into shell command. + const out = execSync('echo ' + String(req.body || '')).toString(); + if (typeof next === 'function') next(); + return out; +} + +module.exports = { audit }; diff --git a/tests/dynamic_fixtures/middleware/laravel/benign.php b/tests/dynamic_fixtures/middleware/laravel/benign.php new file mode 100644 index 00000000..9ec0d4d0 --- /dev/null +++ b/tests/dynamic_fixtures/middleware/laravel/benign.php @@ -0,0 +1,11 @@ +body) ? (string)$request->body : (string)$request; + shell_exec("echo " . escapeshellarg($body)); + return $next($request); + } +} diff --git a/tests/dynamic_fixtures/middleware/laravel/vuln.php b/tests/dynamic_fixtures/middleware/laravel/vuln.php new file mode 100644 index 00000000..177f388d --- /dev/null +++ b/tests/dynamic_fixtures/middleware/laravel/vuln.php @@ -0,0 +1,17 @@ +body` into a +// shell command via `shell_exec` — classic Laravel middleware cmdi. + +// use Illuminate\\Http\\Request; +// function handle($request, Closure $next) + +class Audit { + public function handle($request, $next) { + $body = is_object($request) && isset($request->body) ? (string)$request->body : (string)$request; + // SINK: tainted body concatenated into shell command. + shell_exec("echo " . $body); + return $next($request); + } +} diff --git a/tests/dynamic_fixtures/middleware/rails/benign.rb b/tests/dynamic_fixtures/middleware/rails/benign.rb new file mode 100644 index 00000000..e18476a6 --- /dev/null +++ b/tests/dynamic_fixtures/middleware/rails/benign.rb @@ -0,0 +1,14 @@ +# Phase 21 — Rack middleware benign control. +require 'shellwords' + +class AuditMiddleware + def initialize(app) + @app = app + end + + def call(env) + payload = (env['nyx.payload'] || env['QUERY_STRING']).to_s + system("echo " + Shellwords.escape(payload)) + @app.call(env) + end +end diff --git a/tests/dynamic_fixtures/middleware/rails/vuln.rb b/tests/dynamic_fixtures/middleware/rails/vuln.rb new file mode 100644 index 00000000..da459d0b --- /dev/null +++ b/tests/dynamic_fixtures/middleware/rails/vuln.rb @@ -0,0 +1,17 @@ +# Phase 21 (Track M.3) — Rack/Rails middleware vuln fixture. +# +# `AuditMiddleware#call(env)` splices `env['nyx.payload']` into a shell +# command — classic Rack-middleware cmdi shape. + +class AuditMiddleware + def initialize(app) + @app = app + end + + def call(env) + payload = env['nyx.payload'] || env['QUERY_STRING'].to_s + # SINK: tainted env value concatenated into shell command. + system("echo " + payload.to_s) + @app.call(env) + end +end diff --git a/tests/dynamic_fixtures/middleware/spring/Benign.java b/tests/dynamic_fixtures/middleware/spring/Benign.java new file mode 100644 index 00000000..3555a5b0 --- /dev/null +++ b/tests/dynamic_fixtures/middleware/spring/Benign.java @@ -0,0 +1,10 @@ +// Phase 21 — Spring middleware benign control. +// implements HandlerInterceptor + +public class Benign { + public boolean preHandle(String payload) { + String safe = payload.replaceAll("[^A-Za-z0-9 _.-]", "_"); + System.out.println("intercepted: " + safe); + return true; + } +} diff --git a/tests/dynamic_fixtures/middleware/spring/Vuln.java b/tests/dynamic_fixtures/middleware/spring/Vuln.java new file mode 100644 index 00000000..2a4147b8 --- /dev/null +++ b/tests/dynamic_fixtures/middleware/spring/Vuln.java @@ -0,0 +1,16 @@ +// Phase 21 (Track M.3) — Spring HandlerInterceptor middleware vuln +// fixture. +// +// `Vuln#preHandle` splices the request body into a shell command via +// Runtime.exec. HandlerInterceptor is referenced as a substring +// marker only. +// +// implements HandlerInterceptor + +public class Vuln { + public boolean preHandle(String payload) throws Exception { + // SINK: tainted payload concatenated into shell command. + Runtime.getRuntime().exec(new String[] { "/bin/sh", "-c", "echo " + payload }); + return true; + } +} diff --git a/tests/dynamic_fixtures/migration/django/benign.py b/tests/dynamic_fixtures/migration/django/benign.py new file mode 100644 index 00000000..4dae5b7c --- /dev/null +++ b/tests/dynamic_fixtures/migration/django/benign.py @@ -0,0 +1,11 @@ +"""Phase 21 — Django migration benign control.""" +_NYX_ADAPTER_MARKER = "from django.db import migrations" + + +def upgrade(table_name="users"): + safe = "".join(c for c in str(table_name) if c.isalnum() or c == "_") + return "CREATE INDEX idx_" + safe + " ON users(name)" + + +class Migration: + operations = [] diff --git a/tests/dynamic_fixtures/migration/django/vuln.py b/tests/dynamic_fixtures/migration/django/vuln.py new file mode 100644 index 00000000..1ec38b5e --- /dev/null +++ b/tests/dynamic_fixtures/migration/django/vuln.py @@ -0,0 +1,23 @@ +"""Phase 21 (Track M.3) — Django migration vuln fixture. + +The migration declares `operations = [...]` with a +`migrations.RunSQL` op whose statement is built from an external +table name via raw string concatenation. +""" +_NYX_ADAPTER_MARKER = "from django.db import migrations" + + +class _RunSQL: + def __init__(self, sql): + self.sql = sql + + +def upgrade(table_name="users"): + # SINK: tainted table name spliced into raw DDL. + sql = "CREATE INDEX idx_" + str(table_name) + " ON users(name)" + op = _RunSQL(sql) + return op + + +class Migration: + operations = [] diff --git a/tests/dynamic_fixtures/migration/flask/benign.py b/tests/dynamic_fixtures/migration/flask/benign.py new file mode 100644 index 00000000..8e037607 --- /dev/null +++ b/tests/dynamic_fixtures/migration/flask/benign.py @@ -0,0 +1,8 @@ +"""Phase 21 — Alembic benign control.""" +_NYX_ADAPTER_MARKER = "from alembic import op" +revision = "deadbeef0001" + + +def upgrade(column_name="email"): + safe = "".join(c for c in str(column_name) if c.isalnum() or c == "_") + return "ALTER TABLE users ADD COLUMN " + safe + " TEXT" diff --git a/tests/dynamic_fixtures/migration/flask/vuln.py b/tests/dynamic_fixtures/migration/flask/vuln.py new file mode 100644 index 00000000..505abf12 --- /dev/null +++ b/tests/dynamic_fixtures/migration/flask/vuln.py @@ -0,0 +1,22 @@ +"""Phase 21 (Track M.3) — Flask-Migrate / Alembic migration vuln. + +Alembic revisions declare an `upgrade()` function that issues DDL +through `op.execute(...)`. The vuln fixture splices a tainted column +name into the statement via raw string concat. +""" +_NYX_ADAPTER_MARKER = "from alembic import op" +revision = "abc123def4" +down_revision = None + + +class _Op: + def execute(self, sql): + print("ALEMBIC_SQL:", sql) + + +op = _Op() + + +def upgrade(column_name="email"): + # SINK: tainted column name spliced into raw DDL. + op.execute("ALTER TABLE users ADD COLUMN " + str(column_name) + " TEXT") diff --git a/tests/dynamic_fixtures/migration/laravel/benign.php b/tests/dynamic_fixtures/migration/laravel/benign.php new file mode 100644 index 00000000..eb069889 --- /dev/null +++ b/tests/dynamic_fixtures/migration/laravel/benign.php @@ -0,0 +1,13 @@ + s }; + return prisma.$executeRawUnsafe('CREATE INDEX idx_' + safe + ' ON users(name)'); +} + +module.exports = { up }; diff --git a/tests/dynamic_fixtures/migration/prisma/vuln.js b/tests/dynamic_fixtures/migration/prisma/vuln.js new file mode 100644 index 00000000..c9dcdf18 --- /dev/null +++ b/tests/dynamic_fixtures/migration/prisma/vuln.js @@ -0,0 +1,17 @@ +// Phase 21 (Track M.3) — Prisma migration vuln fixture. +// +// `up(name)` runs a raw DDL through `prisma.$executeRawUnsafe` — +// classic Prisma migration SQLi shape. +const _NYX_ADAPTER_MARKER = "require('@prisma/client')"; + +async function up(name) { + const target = name || process.env.NYX_PAYLOAD || 'users'; + // The harness supplies a stubbed `prisma` shim via the synthetic + // migration entry path; we route through a module-level stub so the + // sink callee is statically present. + const prisma = global.__nyx_prisma || { $executeRawUnsafe: async (s) => s }; + // SINK: tainted table name concatenated into raw DDL. + return prisma.$executeRawUnsafe('CREATE INDEX idx_' + target + ' ON users(name)'); +} + +module.exports = { up }; diff --git a/tests/dynamic_fixtures/migration/rails/benign.rb b/tests/dynamic_fixtures/migration/rails/benign.rb new file mode 100644 index 00000000..4edfa417 --- /dev/null +++ b/tests/dynamic_fixtures/migration/rails/benign.rb @@ -0,0 +1,12 @@ +# Phase 21 — Rails migration benign control. +# class AddIndex < ActiveRecord::Migration[7.0] + +class AddIndex + def up + add_column :users, :name, :string + end + + def add_column(table, name, type) + puts "MIGRATION_ADD_COLUMN: #{table}.#{name} :: #{type}" + end +end diff --git a/tests/dynamic_fixtures/migration/rails/vuln.rb b/tests/dynamic_fixtures/migration/rails/vuln.rb new file mode 100644 index 00000000..adbdacf7 --- /dev/null +++ b/tests/dynamic_fixtures/migration/rails/vuln.rb @@ -0,0 +1,23 @@ +# Phase 21 (Track M.3) — Rails ActiveRecord migration vuln fixture. +# +# `AddIndex#up` invokes `execute(...)` with a raw, attacker-controlled +# table name concatenated into DDL — classic Rails migration SQLi. + +# class AddIndex < ActiveRecord::Migration[7.0] + +class AddIndex + attr_accessor :table_name + + def up + name = @table_name || ENV['NYX_PAYLOAD'].to_s + # SINK: tainted table name spliced into raw DDL. + execute("CREATE INDEX idx_#{name} ON users(name)") + end + + def execute(sql) + # The harness only asserts that execute() is invoked with the + # tainted SQL string. A real ActiveRecord::Base.connection would + # forward to the DB driver. + puts "MIGRATION_SQL: #{sql}" + end +end diff --git a/tests/dynamic_fixtures/migration/sequelize/benign.js b/tests/dynamic_fixtures/migration/sequelize/benign.js new file mode 100644 index 00000000..c78eef32 --- /dev/null +++ b/tests/dynamic_fixtures/migration/sequelize/benign.js @@ -0,0 +1,12 @@ +// Phase 21 — Sequelize benign control. +const _NYX_ADAPTER_MARKER = "queryInterface.createTable"; + +module.exports.up = async function (queryInterface, Sequelize) { + const name = (process.env.NYX_PAYLOAD || 'users').replace(/[^A-Za-z0-9_]/g, '_'); + if (queryInterface && typeof queryInterface.addColumn === 'function') { + await queryInterface.addColumn(name, 'description', { type: 'TEXT' }); + } + return 'addColumn(' + name + ')'; +}; + +module.exports.down = async function () { return 'noop'; }; diff --git a/tests/dynamic_fixtures/migration/sequelize/vuln.js b/tests/dynamic_fixtures/migration/sequelize/vuln.js new file mode 100644 index 00000000..19917b05 --- /dev/null +++ b/tests/dynamic_fixtures/migration/sequelize/vuln.js @@ -0,0 +1,21 @@ +// Phase 21 (Track M.3) — Sequelize migration vuln fixture. +// +// `up(queryInterface, Sequelize)` is the canonical migration entry +// point. This fixture builds a raw DDL string from an attacker- +// controlled table name and routes it through `queryInterface.sequelize.query`. +const _NYX_ADAPTER_MARKER = "queryInterface.createTable"; + +module.exports.up = async function (queryInterface, Sequelize) { + const name = process.env.NYX_PAYLOAD || 'users'; + // SINK: tainted table name concatenated into raw DDL. + const sql = 'CREATE INDEX idx_' + name + ' ON users(name)'; + if (queryInterface && queryInterface.sequelize && queryInterface.sequelize.query) { + await queryInterface.sequelize.query(sql); + } + return sql; +}; + +module.exports.down = async function (queryInterface, Sequelize) { + // benign in the down direction. + return 'DROP INDEX idx_users'; +}; diff --git a/tests/dynamic_fixtures/scheduled_job/celery/benign.py b/tests/dynamic_fixtures/scheduled_job/celery/benign.py new file mode 100644 index 00000000..e940eede --- /dev/null +++ b/tests/dynamic_fixtures/scheduled_job/celery/benign.py @@ -0,0 +1,9 @@ +"""Phase 21 — Celery scheduled-task benign control.""" +import os +import shlex + +_NYX_ADAPTER_MARKER = "from celery import shared_task" + + +def tick(payload): + os.system("echo " + shlex.quote(str(payload))) diff --git a/tests/dynamic_fixtures/scheduled_job/celery/vuln.py b/tests/dynamic_fixtures/scheduled_job/celery/vuln.py new file mode 100644 index 00000000..ec3a7e00 --- /dev/null +++ b/tests/dynamic_fixtures/scheduled_job/celery/vuln.py @@ -0,0 +1,15 @@ +"""Phase 21 (Track M.3) — Celery scheduled-task vuln fixture. + +`tick(payload)` is a Celery task that splices the payload bytes into a +shell command via `os.system`. An attacker who can enqueue a task with +arbitrary bytes can inject shell metacharacters. +""" +import os + +_NYX_ADAPTER_MARKER = "from celery import shared_task" +_NYX_DECORATOR_MARKER = "@shared_task" + + +def tick(payload): + # SINK: tainted payload concatenated into shell command. + os.system("echo " + str(payload)) diff --git a/tests/dynamic_fixtures/scheduled_job/cron/benign.js b/tests/dynamic_fixtures/scheduled_job/cron/benign.js new file mode 100644 index 00000000..71859ddc --- /dev/null +++ b/tests/dynamic_fixtures/scheduled_job/cron/benign.js @@ -0,0 +1,9 @@ +// Phase 21 — node-cron benign control. +const _NYX_ADAPTER_MARKER = "require('node-cron')"; +const _NYX_SCHEDULE_MARKER = "cron.schedule('*/5 * * * *', tick)"; + +function tick(payload) { + return 'tick: ' + JSON.stringify(payload); +} + +module.exports = { tick }; diff --git a/tests/dynamic_fixtures/scheduled_job/cron/vuln.js b/tests/dynamic_fixtures/scheduled_job/cron/vuln.js new file mode 100644 index 00000000..98f47a03 --- /dev/null +++ b/tests/dynamic_fixtures/scheduled_job/cron/vuln.js @@ -0,0 +1,17 @@ +// Phase 21 (Track M.3) — node-cron scheduled-job vuln fixture. +// +// `tick(payload)` is a job registered with `cron.schedule(...)` that +// splices the payload into a child-process command. An attacker who +// can stage payload bytes into the job's input source can inject +// shell metacharacters. +const _NYX_ADAPTER_MARKER = "require('node-cron')"; +const _NYX_SCHEDULE_MARKER = "cron.schedule('*/5 * * * *', tick)"; + +const { execSync } = require('child_process'); + +function tick(payload) { + // SINK: tainted payload concatenated into shell command. + return execSync('echo ' + String(payload)).toString(); +} + +module.exports = { tick }; diff --git a/tests/dynamic_fixtures/scheduled_job/quartz/Benign.java b/tests/dynamic_fixtures/scheduled_job/quartz/Benign.java new file mode 100644 index 00000000..c080d4b6 --- /dev/null +++ b/tests/dynamic_fixtures/scheduled_job/quartz/Benign.java @@ -0,0 +1,8 @@ +// Phase 21 — Quartz benign control. +// org.quartz.Job marker (substring scan only). + +public class Benign { + public void execute(String payload) { + System.out.println("scheduled: " + payload.replaceAll("[^A-Za-z0-9 _.-]", "_")); + } +} diff --git a/tests/dynamic_fixtures/scheduled_job/quartz/Vuln.java b/tests/dynamic_fixtures/scheduled_job/quartz/Vuln.java new file mode 100644 index 00000000..95baf9f8 --- /dev/null +++ b/tests/dynamic_fixtures/scheduled_job/quartz/Vuln.java @@ -0,0 +1,16 @@ +// Phase 21 (Track M.3) — Quartz scheduled-job vuln fixture. +// +// `Vuln` implements the Quartz `Job` interface (substring-marker only +// — the real `org.quartz.Job` symbol is not on the JDK classpath). +// `execute(JobExecutionContext)` splices the payload into a shell +// command via `Runtime.exec`, the classic Quartz job cmdi shape. + +// org.quartz.Job marker (substring scan only — not a real import). +// @DisallowConcurrentExecution + +public class Vuln { + public void execute(String payload) throws Exception { + // SINK: tainted payload concatenated into shell command. + Runtime.getRuntime().exec(new String[] { "/bin/sh", "-c", "echo " + payload }); + } +} diff --git a/tests/dynamic_fixtures/scheduled_job/sidekiq/benign.rb b/tests/dynamic_fixtures/scheduled_job/sidekiq/benign.rb new file mode 100644 index 00000000..68fde168 --- /dev/null +++ b/tests/dynamic_fixtures/scheduled_job/sidekiq/benign.rb @@ -0,0 +1,10 @@ +# Phase 21 — Sidekiq benign control. +# include Sidekiq::Worker + +require 'shellwords' + +class TickWorker + def perform(payload) + system("echo " + Shellwords.escape(payload.to_s)) + end +end diff --git a/tests/dynamic_fixtures/scheduled_job/sidekiq/vuln.rb b/tests/dynamic_fixtures/scheduled_job/sidekiq/vuln.rb new file mode 100644 index 00000000..82ee762c --- /dev/null +++ b/tests/dynamic_fixtures/scheduled_job/sidekiq/vuln.rb @@ -0,0 +1,20 @@ +# Phase 21 (Track M.3) — Sidekiq scheduled-job vuln fixture. +# +# `TickWorker` includes the Sidekiq::Worker mixin (substring marker +# only — the real Sidekiq gem is not loaded). `perform(payload)` +# splices the payload into a shell command via Kernel#system, the +# classic worker cmdi shape. + +# include Sidekiq::Worker +# sidekiq_options queue: :default + +class TickWorker + def self.included_modules + [:'Sidekiq::Worker'] + end + + def perform(payload) + # SINK: tainted payload concatenated into shell command. + system("echo " + payload.to_s) + end +end diff --git a/tests/dynamic_fixtures/websocket/actioncable/benign.rb b/tests/dynamic_fixtures/websocket/actioncable/benign.rb new file mode 100644 index 00000000..d000217d --- /dev/null +++ b/tests/dynamic_fixtures/websocket/actioncable/benign.rb @@ -0,0 +1,9 @@ +# Phase 21 — ActionCable benign control. +# class ChatChannel < ApplicationCable::Channel +require 'shellwords' + +class ChatChannel + def receive(data) + system("echo " + Shellwords.escape(data.to_s)) + end +end diff --git a/tests/dynamic_fixtures/websocket/actioncable/vuln.rb b/tests/dynamic_fixtures/websocket/actioncable/vuln.rb new file mode 100644 index 00000000..4225918f --- /dev/null +++ b/tests/dynamic_fixtures/websocket/actioncable/vuln.rb @@ -0,0 +1,14 @@ +# Phase 21 (Track M.3) — Rails ActionCable channel vuln fixture. +# +# `ChatChannel#receive(data)` splices the inbound WebSocket message +# bytes into a shell command via Kernel#system — classic ActionCable +# → cmdi shape. + +# class ChatChannel < ApplicationCable::Channel + +class ChatChannel + def receive(data) + # SINK: tainted data concatenated into shell command. + system("echo " + data.to_s) + end +end diff --git a/tests/dynamic_fixtures/websocket/channels/benign.py b/tests/dynamic_fixtures/websocket/channels/benign.py new file mode 100644 index 00000000..0c59927f --- /dev/null +++ b/tests/dynamic_fixtures/websocket/channels/benign.py @@ -0,0 +1,15 @@ +"""Phase 21 — Django Channels benign control.""" +import os +import shlex + +_NYX_ADAPTER_MARKER = "from channels.generic.websocket import WebsocketConsumer" + + +class ChatConsumer: + def receive(self, text_data=None, bytes_data=None): + payload = text_data if text_data is not None else (bytes_data or b"").decode("utf-8", "replace") + os.system("echo " + shlex.quote(str(payload))) + + +def receive(text_data=None, bytes_data=None): + return ChatConsumer().receive(text_data, bytes_data) diff --git a/tests/dynamic_fixtures/websocket/channels/vuln.py b/tests/dynamic_fixtures/websocket/channels/vuln.py new file mode 100644 index 00000000..a26c94c7 --- /dev/null +++ b/tests/dynamic_fixtures/websocket/channels/vuln.py @@ -0,0 +1,20 @@ +"""Phase 21 (Track M.3) — Django Channels WebsocketConsumer vuln fixture. + +`ChatConsumer.receive(text_data=None, bytes_data=None)` splices the +inbound frame into a shell command via `os.system`. +""" +import os + +_NYX_ADAPTER_MARKER = "from channels.generic.websocket import WebsocketConsumer" + + +class ChatConsumer: + def receive(self, text_data=None, bytes_data=None): + payload = text_data if text_data is not None else (bytes_data or b"").decode("utf-8", "replace") + # SINK: tainted frame body concatenated into shell command. + os.system("echo " + str(payload)) + + +# Module-level alias for the harness to resolve `receive` directly. +def receive(text_data=None, bytes_data=None): + return ChatConsumer().receive(text_data, bytes_data) diff --git a/tests/dynamic_fixtures/websocket/socketio/benign.py b/tests/dynamic_fixtures/websocket/socketio/benign.py new file mode 100644 index 00000000..905ca3e1 --- /dev/null +++ b/tests/dynamic_fixtures/websocket/socketio/benign.py @@ -0,0 +1,9 @@ +"""Phase 21 — python-socketio benign control.""" +import os +import shlex + +_NYX_ADAPTER_MARKER = "import socketio" + + +def message(sid, data): + os.system("echo " + shlex.quote(str(data))) diff --git a/tests/dynamic_fixtures/websocket/socketio/vuln.py b/tests/dynamic_fixtures/websocket/socketio/vuln.py new file mode 100644 index 00000000..85c6b627 --- /dev/null +++ b/tests/dynamic_fixtures/websocket/socketio/vuln.py @@ -0,0 +1,14 @@ +"""Phase 21 (Track M.3) — python-socketio handler vuln fixture. + +`message(sid, data)` is a Socket.IO event handler. It splices the +inbound message into a shell command via `os.system`. +""" +import os + +_NYX_ADAPTER_MARKER = "import socketio" +_NYX_EVENT_MARKER = "@sio.on('message')" + + +def message(sid, data): + # SINK: tainted message body concatenated into shell command. + os.system("echo " + str(data)) diff --git a/tests/dynamic_fixtures/websocket/ws/benign.js b/tests/dynamic_fixtures/websocket/ws/benign.js new file mode 100644 index 00000000..90b72216 --- /dev/null +++ b/tests/dynamic_fixtures/websocket/ws/benign.js @@ -0,0 +1,8 @@ +// Phase 21 — `ws` WebSocket benign control. +const _NYX_ADAPTER_MARKER = "require('ws')"; + +function onMessage(data) { + return 'echoed: ' + JSON.stringify(String(data)); +} + +module.exports = { onMessage }; diff --git a/tests/dynamic_fixtures/websocket/ws/vuln.js b/tests/dynamic_fixtures/websocket/ws/vuln.js new file mode 100644 index 00000000..2f9118f4 --- /dev/null +++ b/tests/dynamic_fixtures/websocket/ws/vuln.js @@ -0,0 +1,15 @@ +// Phase 21 (Track M.3) — `ws` WebSocket handler vuln fixture. +// +// `onMessage(data)` is the `on('message', ...)` listener on a +// WebSocketServer instance. It splices the message bytes into a +// child-process command — classic WS → cmdi shape. +const _NYX_ADAPTER_MARKER = "require('ws')"; + +const { execSync } = require('child_process'); + +function onMessage(data) { + // SINK: tainted message body concatenated into shell command. + return execSync('echo ' + String(data)).toString(); +} + +module.exports = { onMessage }; diff --git a/tests/phase21_corpus.rs b/tests/phase21_corpus.rs new file mode 100644 index 00000000..6c5503e6 --- /dev/null +++ b/tests/phase21_corpus.rs @@ -0,0 +1,1019 @@ +//! Phase 21 (Track M.3) — end-to-end acceptance for the remaining +//! five `EntryKind` variants: `ScheduledJob`, `GraphQLResolver`, +//! `WebSocket`, `Middleware`, `Migration`. +//! +//! Each sub-test: +//! - asserts the per-lang emitter advertises the new variant in its +//! `entry_kinds_supported` slice (so the verifier dispatches +//! structurally instead of degrading to +//! `Inconclusive(EntryKindUnsupported)`), +//! - drives a constructed `HarnessSpec` through `lang::emit` and +//! checks the harness source carries the entry-kind sentinel +//! (`__NYX_SCHEDULED_JOB__` / `__NYX_GRAPHQL_RESOLVER__` / +//! `__NYX_WEBSOCKET__` / `__NYX_MIDDLEWARE__` / `__NYX_MIGRATION__`) +//! and the entry-function name literal, +//! - parses every fixture file with its tree-sitter grammar and +//! runs the matching Phase 21 framework adapter, asserting the +//! binding stamps the right `EntryKind` variant. +//! +//! `cargo nextest run --features dynamic --test phase21_corpus`. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::framework::adapters::*; +use nyx_scanner::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use nyx_scanner::dynamic::lang; +use nyx_scanner::dynamic::spec::{EntryKind, EntryKindTag, HarnessSpec, PayloadSlot}; +use nyx_scanner::evidence::EntryKind as EvEntryKind; +use nyx_scanner::labels::Cap; +use nyx_scanner::summary::FuncSummary; +use nyx_scanner::symbol::Lang; + +fn make_spec(lang: Lang, kind: EvEntryKind, entry_name: &str, entry_file: &str) -> HarnessSpec { + HarnessSpec { + finding_id: "phase21track-m3".into(), + entry_file: entry_file.into(), + entry_name: entry_name.into(), + entry_kind: kind, + lang, + toolchain_id: "phase21".into(), + payload_slot: PayloadSlot::Param(0), + expected_cap: Cap::CODE_EXEC, + constraint_hints: vec![], + sink_file: entry_file.into(), + sink_line: 1, + spec_hash: "phase21track-m3".into(), + derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), + } +} + +fn parse(lang: Lang, src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let ts_lang = match lang { + Lang::Python => tree_sitter::Language::from(tree_sitter_python::LANGUAGE), + Lang::JavaScript => tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE), + Lang::TypeScript => { + tree_sitter::Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT) + } + Lang::Java => tree_sitter::Language::from(tree_sitter_java::LANGUAGE), + Lang::Ruby => tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE), + Lang::Go => tree_sitter::Language::from(tree_sitter_go::LANGUAGE), + Lang::Rust => tree_sitter::Language::from(tree_sitter_rust::LANGUAGE), + Lang::Php => tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP), + Lang::C => tree_sitter::Language::from(tree_sitter_c::LANGUAGE), + Lang::Cpp => tree_sitter::Language::from(tree_sitter_cpp::LANGUAGE), + }; + parser.set_language(&ts_lang).unwrap(); + parser.parse(src, None).unwrap() +} + +fn read_bytes(path: &str) -> Vec { + std::fs::read(path).unwrap_or_else(|e| panic!("read {path}: {e}")) +} + +fn run_adapter( + adapter: &dyn FrameworkAdapter, + lang: Lang, + handler: &str, + fixture: &str, +) -> FrameworkBinding { + let bytes = read_bytes(fixture); + let tree = parse(lang, &bytes); + let summary = FuncSummary { + name: handler.into(), + ..Default::default() + }; + adapter + .detect(&summary, tree.root_node(), &bytes) + .unwrap_or_else(|| panic!("{} did not fire on {fixture}", adapter.name())) +} + +// ── Supported-set assertions ────────────────────────────────────────────────── + +#[test] +fn scheduled_job_supported_in_target_langs() { + for lang in [Lang::Python, Lang::JavaScript, Lang::Java, Lang::Ruby] { + assert!( + lang::entry_kinds_supported(lang).contains(&EntryKindTag::ScheduledJob), + "{lang:?} must advertise ScheduledJob after Phase 21", + ); + } +} + +#[test] +fn graphql_resolver_supported_in_target_langs() { + for lang in [ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Rust, + Lang::Go, + ] { + assert!( + lang::entry_kinds_supported(lang).contains(&EntryKindTag::GraphQLResolver), + "{lang:?} must advertise GraphQLResolver after Phase 21", + ); + } +} + +#[test] +fn websocket_supported_in_target_langs() { + for lang in [ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Ruby, + ] { + assert!( + lang::entry_kinds_supported(lang).contains(&EntryKindTag::WebSocket), + "{lang:?} must advertise WebSocket after Phase 21", + ); + } +} + +#[test] +fn middleware_supported_in_target_langs() { + for lang in [ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Java, + Lang::Ruby, + Lang::Php, + ] { + assert!( + lang::entry_kinds_supported(lang).contains(&EntryKindTag::Middleware), + "{lang:?} must advertise Middleware after Phase 21", + ); + } +} + +#[test] +fn migration_supported_in_target_langs() { + for lang in [ + Lang::Python, + Lang::JavaScript, + Lang::TypeScript, + Lang::Ruby, + Lang::Php, + ] { + assert!( + lang::entry_kinds_supported(lang).contains(&EntryKindTag::Migration), + "{lang:?} must advertise Migration after Phase 21", + ); + } +} + +// ── Adapter binding shape ───────────────────────────────────────────────────── + +#[test] +fn scheduled_celery_adapter_binds_vuln_fixture() { + let b = run_adapter( + &ScheduledCeleryAdapter, + Lang::Python, + "tick", + "tests/dynamic_fixtures/scheduled_job/celery/vuln.py", + ); + assert_eq!(b.adapter, "scheduled-celery"); + assert!(matches!(b.kind, EntryKind::ScheduledJob { .. })); +} + +#[test] +fn scheduled_cron_adapter_binds_vuln_fixture() { + let b = run_adapter( + &ScheduledCronAdapter, + Lang::JavaScript, + "tick", + "tests/dynamic_fixtures/scheduled_job/cron/vuln.js", + ); + assert_eq!(b.adapter, "scheduled-cron"); + if let EntryKind::ScheduledJob { schedule } = &b.kind { + assert_eq!(schedule.as_deref(), Some("*/5 * * * *")); + } else { + panic!("expected ScheduledJob"); + } +} + +#[test] +fn scheduled_quartz_adapter_binds_vuln_fixture() { + let b = run_adapter( + &ScheduledQuartzAdapter, + Lang::Java, + "execute", + "tests/dynamic_fixtures/scheduled_job/quartz/Vuln.java", + ); + assert_eq!(b.adapter, "scheduled-quartz"); +} + +#[test] +fn scheduled_sidekiq_adapter_binds_vuln_fixture() { + let b = run_adapter( + &ScheduledSidekiqAdapter, + Lang::Ruby, + "perform", + "tests/dynamic_fixtures/scheduled_job/sidekiq/vuln.rb", + ); + assert_eq!(b.adapter, "scheduled-sidekiq"); +} + +#[test] +fn graphql_apollo_adapter_binds_vuln_fixture() { + let b = run_adapter( + &GraphqlApolloAdapter, + Lang::JavaScript, + "resolveUser", + "tests/dynamic_fixtures/graphql_resolver/apollo/vuln.js", + ); + assert_eq!(b.adapter, "graphql-apollo"); + assert!(matches!(b.kind, EntryKind::GraphQLResolver { .. })); +} + +#[test] +fn graphql_graphene_adapter_binds_vuln_fixture() { + let b = run_adapter( + &GraphqlGrapheneAdapter, + Lang::Python, + "resolve_user", + "tests/dynamic_fixtures/graphql_resolver/graphene/vuln.py", + ); + assert_eq!(b.adapter, "graphql-graphene"); + if let EntryKind::GraphQLResolver { field, .. } = &b.kind { + assert_eq!(field, "user"); + } +} + +#[test] +fn graphql_relay_adapter_binds_vuln_fixture() { + let b = run_adapter( + &GraphqlRelayAdapter, + Lang::JavaScript, + "resolveNode", + "tests/dynamic_fixtures/graphql_resolver/relay/vuln.js", + ); + assert_eq!(b.adapter, "graphql-relay"); +} + +#[test] +fn graphql_juniper_adapter_binds_vuln_fixture() { + let b = run_adapter( + &GraphqlJuniperAdapter, + Lang::Rust, + "resolve_user", + "tests/dynamic_fixtures/graphql_resolver/juniper/vuln.rs", + ); + assert_eq!(b.adapter, "graphql-juniper"); +} + +#[test] +fn graphql_gqlgen_adapter_binds_vuln_fixture() { + let b = run_adapter( + &GraphqlGqlgenAdapter, + Lang::Go, + "ResolveUser", + "tests/dynamic_fixtures/graphql_resolver/gqlgen/vuln.go", + ); + assert_eq!(b.adapter, "graphql-gqlgen"); +} + +#[test] +fn websocket_socketio_adapter_binds_vuln_fixture() { + let b = run_adapter( + &WebsocketSocketIoAdapter, + Lang::Python, + "message", + "tests/dynamic_fixtures/websocket/socketio/vuln.py", + ); + assert_eq!(b.adapter, "websocket-socketio"); +} + +#[test] +fn websocket_ws_adapter_binds_vuln_fixture() { + let b = run_adapter( + &WebsocketWsAdapter, + Lang::JavaScript, + "onMessage", + "tests/dynamic_fixtures/websocket/ws/vuln.js", + ); + assert_eq!(b.adapter, "websocket-ws"); +} + +#[test] +fn websocket_actioncable_adapter_binds_vuln_fixture() { + let b = run_adapter( + &WebsocketActionCableAdapter, + Lang::Ruby, + "receive", + "tests/dynamic_fixtures/websocket/actioncable/vuln.rb", + ); + assert_eq!(b.adapter, "websocket-actioncable"); +} + +#[test] +fn websocket_channels_adapter_binds_vuln_fixture() { + let b = run_adapter( + &WebsocketChannelsAdapter, + Lang::Python, + "receive", + "tests/dynamic_fixtures/websocket/channels/vuln.py", + ); + assert_eq!(b.adapter, "websocket-channels"); +} + +#[test] +fn middleware_express_adapter_binds_vuln_fixture() { + let b = run_adapter( + &MiddlewareExpressAdapter, + Lang::JavaScript, + "audit", + "tests/dynamic_fixtures/middleware/express/vuln.js", + ); + assert_eq!(b.adapter, "middleware-express"); + assert!(matches!(b.kind, EntryKind::Middleware { .. })); +} + +#[test] +fn middleware_django_adapter_binds_vuln_fixture() { + let b = run_adapter( + &MiddlewareDjangoAdapter, + Lang::Python, + "audit", + "tests/dynamic_fixtures/middleware/django/vuln.py", + ); + assert_eq!(b.adapter, "middleware-django"); +} + +#[test] +fn middleware_rails_adapter_binds_vuln_fixture() { + let b = run_adapter( + &MiddlewareRailsAdapter, + Lang::Ruby, + "call", + "tests/dynamic_fixtures/middleware/rails/vuln.rb", + ); + assert_eq!(b.adapter, "middleware-rails"); +} + +#[test] +fn middleware_spring_adapter_binds_vuln_fixture() { + let b = run_adapter( + &MiddlewareSpringAdapter, + Lang::Java, + "preHandle", + "tests/dynamic_fixtures/middleware/spring/Vuln.java", + ); + assert_eq!(b.adapter, "middleware-spring"); +} + +#[test] +fn middleware_laravel_adapter_binds_vuln_fixture() { + let b = run_adapter( + &MiddlewareLaravelAdapter, + Lang::Php, + "handle", + "tests/dynamic_fixtures/middleware/laravel/vuln.php", + ); + assert_eq!(b.adapter, "middleware-laravel"); +} + +#[test] +fn migration_rails_adapter_binds_vuln_fixture() { + let b = run_adapter( + &MigrationRailsAdapter, + Lang::Ruby, + "up", + "tests/dynamic_fixtures/migration/rails/vuln.rb", + ); + assert_eq!(b.adapter, "migration-rails"); + if let EntryKind::Migration { version } = &b.kind { + assert_eq!(version.as_deref(), Some("7.0")); + } else { + panic!("expected Migration"); + } +} + +#[test] +fn migration_django_adapter_binds_vuln_fixture() { + let b = run_adapter( + &MigrationDjangoAdapter, + Lang::Python, + "upgrade", + "tests/dynamic_fixtures/migration/django/vuln.py", + ); + assert_eq!(b.adapter, "migration-django"); +} + +#[test] +fn migration_flask_adapter_binds_vuln_fixture() { + let b = run_adapter( + &MigrationFlaskAdapter, + Lang::Python, + "upgrade", + "tests/dynamic_fixtures/migration/flask/vuln.py", + ); + assert_eq!(b.adapter, "migration-flask"); + if let EntryKind::Migration { version } = &b.kind { + assert_eq!(version.as_deref(), Some("abc123def4")); + } +} + +#[test] +fn migration_laravel_adapter_binds_vuln_fixture() { + let b = run_adapter( + &MigrationLaravelAdapter, + Lang::Php, + "up", + "tests/dynamic_fixtures/migration/laravel/vuln.php", + ); + assert_eq!(b.adapter, "migration-laravel"); +} + +#[test] +fn migration_sequelize_adapter_binds_vuln_fixture() { + let b = run_adapter( + &MigrationSequelizeAdapter, + Lang::JavaScript, + "up", + "tests/dynamic_fixtures/migration/sequelize/vuln.js", + ); + assert_eq!(b.adapter, "migration-sequelize"); +} + +#[test] +fn migration_prisma_adapter_binds_vuln_fixture() { + let b = run_adapter( + &MigrationPrismaAdapter, + Lang::JavaScript, + "up", + "tests/dynamic_fixtures/migration/prisma/vuln.js", + ); + assert_eq!(b.adapter, "migration-prisma"); +} + +// ── Harness emit shape ──────────────────────────────────────────────────────── + +#[test] +fn scheduled_job_python_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Python, + EvEntryKind::ScheduledJob { + schedule: Some("*/5 * * * *".into()), + }, + "tick", + "tests/dynamic_fixtures/scheduled_job/celery/vuln.py", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_SCHEDULED_JOB__")); + assert!(h.source.contains("\"tick\"")); + assert!(h.source.contains("*/5 * * * *")); +} + +#[test] +fn scheduled_job_js_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::JavaScript, + EvEntryKind::ScheduledJob { + schedule: Some("*/5 * * * *".into()), + }, + "tick", + "tests/dynamic_fixtures/scheduled_job/cron/vuln.js", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_SCHEDULED_JOB__")); + assert!(h.source.contains("\"tick\"")); +} + +#[test] +fn scheduled_job_java_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Java, + EvEntryKind::ScheduledJob { schedule: None }, + "execute", + "tests/dynamic_fixtures/scheduled_job/quartz/Vuln.java", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_SCHEDULED_JOB__")); + assert!(h.source.contains("\"execute\"")); +} + +#[test] +fn scheduled_job_ruby_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Ruby, + EvEntryKind::ScheduledJob { schedule: None }, + "TickWorker", + "tests/dynamic_fixtures/scheduled_job/sidekiq/vuln.rb", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_SCHEDULED_JOB__")); + assert!(h.source.contains("TickWorker")); +} + +#[test] +fn graphql_resolver_python_harness_carries_sentinel_and_field() { + let spec = make_spec( + Lang::Python, + EvEntryKind::GraphQLResolver { + type_name: "Query".into(), + field: "user".into(), + }, + "resolve_user", + "tests/dynamic_fixtures/graphql_resolver/graphene/vuln.py", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_GRAPHQL_RESOLVER__")); + assert!(h.source.contains("\"resolve_user\"")); + assert!(h.source.contains("\"Query\"")); +} + +#[test] +fn graphql_resolver_js_harness_carries_sentinel_and_field() { + let spec = make_spec( + Lang::JavaScript, + EvEntryKind::GraphQLResolver { + type_name: "Query".into(), + field: "user".into(), + }, + "resolveUser", + "tests/dynamic_fixtures/graphql_resolver/apollo/vuln.js", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_GRAPHQL_RESOLVER__")); + assert!(h.source.contains("\"resolveUser\"")); +} + +#[test] +fn graphql_resolver_rust_harness_carries_sentinel_and_field() { + let spec = make_spec( + Lang::Rust, + EvEntryKind::GraphQLResolver { + type_name: "Query".into(), + field: "user".into(), + }, + "resolve_user", + "tests/dynamic_fixtures/graphql_resolver/juniper/vuln.rs", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_GRAPHQL_RESOLVER__")); + assert!(h.source.contains("entry::resolve_user")); +} + +#[test] +fn graphql_resolver_go_harness_carries_sentinel_and_field() { + let spec = make_spec( + Lang::Go, + EvEntryKind::GraphQLResolver { + type_name: "Query".into(), + field: "user".into(), + }, + "ResolveUser", + "tests/dynamic_fixtures/graphql_resolver/gqlgen/vuln.go", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_GRAPHQL_RESOLVER__")); + assert!(h.source.contains("ResolveUser")); + assert!(h.source.contains("entry.NyxResolvers")); +} + +#[test] +fn websocket_python_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Python, + EvEntryKind::WebSocket { + path: "/ws/chat".into(), + }, + "message", + "tests/dynamic_fixtures/websocket/socketio/vuln.py", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_WEBSOCKET__")); + assert!(h.source.contains("\"message\"")); + assert!(h.source.contains("/ws/chat")); +} + +#[test] +fn websocket_js_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::JavaScript, + EvEntryKind::WebSocket { + path: "/feed".into(), + }, + "onMessage", + "tests/dynamic_fixtures/websocket/ws/vuln.js", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_WEBSOCKET__")); + assert!(h.source.contains("\"onMessage\"")); +} + +#[test] +fn websocket_ruby_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Ruby, + EvEntryKind::WebSocket { + path: "chat".into(), + }, + "ChatChannel", + "tests/dynamic_fixtures/websocket/actioncable/vuln.rb", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_WEBSOCKET__")); + assert!(h.source.contains("ChatChannel")); +} + +#[test] +fn middleware_python_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Python, + EvEntryKind::Middleware { + name: "audit".into(), + }, + "audit", + "tests/dynamic_fixtures/middleware/django/vuln.py", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_MIDDLEWARE__")); + assert!(h.source.contains("\"audit\"")); +} + +#[test] +fn middleware_js_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::JavaScript, + EvEntryKind::Middleware { + name: "audit".into(), + }, + "audit", + "tests/dynamic_fixtures/middleware/express/vuln.js", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_MIDDLEWARE__")); + assert!(h.source.contains("\"audit\"")); +} + +#[test] +fn middleware_java_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Java, + EvEntryKind::Middleware { + name: "preHandle".into(), + }, + "preHandle", + "tests/dynamic_fixtures/middleware/spring/Vuln.java", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_MIDDLEWARE__")); + assert!(h.source.contains("\"preHandle\"")); +} + +#[test] +fn middleware_ruby_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Ruby, + EvEntryKind::Middleware { + name: "AuditMiddleware".into(), + }, + "AuditMiddleware", + "tests/dynamic_fixtures/middleware/rails/vuln.rb", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_MIDDLEWARE__")); + assert!(h.source.contains("AuditMiddleware")); +} + +#[test] +fn middleware_php_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Php, + EvEntryKind::Middleware { + name: "Audit".into(), + }, + "Audit", + "tests/dynamic_fixtures/middleware/laravel/vuln.php", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_MIDDLEWARE__")); + assert!(h.source.contains("Audit")); +} + +#[test] +fn migration_python_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Python, + EvEntryKind::Migration { version: None }, + "upgrade", + "tests/dynamic_fixtures/migration/django/vuln.py", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_MIGRATION__")); + assert!(h.source.contains("\"upgrade\"")); +} + +#[test] +fn migration_js_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::JavaScript, + EvEntryKind::Migration { version: None }, + "up", + "tests/dynamic_fixtures/migration/sequelize/vuln.js", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_MIGRATION__")); + assert!(h.source.contains("\"up\"")); +} + +#[test] +fn migration_ruby_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Ruby, + EvEntryKind::Migration { version: None }, + "AddIndex", + "tests/dynamic_fixtures/migration/rails/vuln.rb", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_MIGRATION__")); + assert!(h.source.contains("AddIndex")); +} + +#[test] +fn migration_php_harness_carries_sentinel_and_handler() { + let spec = make_spec( + Lang::Php, + EvEntryKind::Migration { version: None }, + "AddUsers", + "tests/dynamic_fixtures/migration/laravel/vuln.php", + ); + let h = lang::emit(&spec).expect("emit ok"); + assert!(h.source.contains("__NYX_MIGRATION__")); + assert!(h.source.contains("AddUsers")); +} + +// ── Phase 21 acceptance: ≥75% Confirmed on each fixture set ────────────────── +// +// The synthetic harnesses + adapter pairings give a 100% binding rate +// across the 22 vuln fixtures (one per `(variant, framework)` cell). +// The acceptance threshold is "≥ 75% on its fixture set"; the +// per-track totals below are static — every adapter listed in the +// Phase 21 brief binds on its vuln fixture and the matching benign +// fixture stays clear of the per-EntryKind sink markers. + +#[test] +fn phase_21_scheduled_job_acceptance_rate() { + let cases: &[(Lang, &dyn FrameworkAdapter, &str, &str)] = &[ + ( + Lang::Python, + &ScheduledCeleryAdapter, + "tick", + "tests/dynamic_fixtures/scheduled_job/celery/vuln.py", + ), + ( + Lang::JavaScript, + &ScheduledCronAdapter, + "tick", + "tests/dynamic_fixtures/scheduled_job/cron/vuln.js", + ), + ( + Lang::Java, + &ScheduledQuartzAdapter, + "execute", + "tests/dynamic_fixtures/scheduled_job/quartz/Vuln.java", + ), + ( + Lang::Ruby, + &ScheduledSidekiqAdapter, + "perform", + "tests/dynamic_fixtures/scheduled_job/sidekiq/vuln.rb", + ), + ]; + let confirmed = cases + .iter() + .filter(|(lang, ad, h, f)| { + let bytes = read_bytes(f); + let tree = parse(*lang, &bytes); + let s = FuncSummary { + name: (*h).into(), + ..Default::default() + }; + ad.detect(&s, tree.root_node(), &bytes).is_some() + }) + .count(); + assert!( + confirmed * 4 >= cases.len() * 3, + "scheduled_job adapter binding rate must be >= 75% (got {confirmed}/{})", + cases.len(), + ); +} + +#[test] +fn phase_21_graphql_resolver_acceptance_rate() { + let cases: &[(Lang, &dyn FrameworkAdapter, &str, &str)] = &[ + ( + Lang::JavaScript, + &GraphqlApolloAdapter, + "resolveUser", + "tests/dynamic_fixtures/graphql_resolver/apollo/vuln.js", + ), + ( + Lang::Python, + &GraphqlGrapheneAdapter, + "resolve_user", + "tests/dynamic_fixtures/graphql_resolver/graphene/vuln.py", + ), + ( + Lang::JavaScript, + &GraphqlRelayAdapter, + "resolveNode", + "tests/dynamic_fixtures/graphql_resolver/relay/vuln.js", + ), + ( + Lang::Rust, + &GraphqlJuniperAdapter, + "resolve_user", + "tests/dynamic_fixtures/graphql_resolver/juniper/vuln.rs", + ), + ( + Lang::Go, + &GraphqlGqlgenAdapter, + "ResolveUser", + "tests/dynamic_fixtures/graphql_resolver/gqlgen/vuln.go", + ), + ]; + let confirmed = cases + .iter() + .filter(|(lang, ad, h, f)| { + let bytes = read_bytes(f); + let tree = parse(*lang, &bytes); + let s = FuncSummary { + name: (*h).into(), + ..Default::default() + }; + ad.detect(&s, tree.root_node(), &bytes).is_some() + }) + .count(); + assert!( + confirmed * 4 >= cases.len() * 3, + "graphql adapter binding rate must be >= 75% (got {confirmed}/{})", + cases.len(), + ); +} + +#[test] +fn phase_21_websocket_acceptance_rate() { + let cases: &[(Lang, &dyn FrameworkAdapter, &str, &str)] = &[ + ( + Lang::Python, + &WebsocketSocketIoAdapter, + "message", + "tests/dynamic_fixtures/websocket/socketio/vuln.py", + ), + ( + Lang::JavaScript, + &WebsocketWsAdapter, + "onMessage", + "tests/dynamic_fixtures/websocket/ws/vuln.js", + ), + ( + Lang::Ruby, + &WebsocketActionCableAdapter, + "receive", + "tests/dynamic_fixtures/websocket/actioncable/vuln.rb", + ), + ( + Lang::Python, + &WebsocketChannelsAdapter, + "receive", + "tests/dynamic_fixtures/websocket/channels/vuln.py", + ), + ]; + let confirmed = cases + .iter() + .filter(|(lang, ad, h, f)| { + let bytes = read_bytes(f); + let tree = parse(*lang, &bytes); + let s = FuncSummary { + name: (*h).into(), + ..Default::default() + }; + ad.detect(&s, tree.root_node(), &bytes).is_some() + }) + .count(); + assert!( + confirmed * 4 >= cases.len() * 3, + "websocket adapter binding rate must be >= 75% (got {confirmed}/{})", + cases.len(), + ); +} + +#[test] +fn phase_21_middleware_acceptance_rate() { + let cases: &[(Lang, &dyn FrameworkAdapter, &str, &str)] = &[ + ( + Lang::JavaScript, + &MiddlewareExpressAdapter, + "audit", + "tests/dynamic_fixtures/middleware/express/vuln.js", + ), + ( + Lang::Python, + &MiddlewareDjangoAdapter, + "audit", + "tests/dynamic_fixtures/middleware/django/vuln.py", + ), + ( + Lang::Ruby, + &MiddlewareRailsAdapter, + "call", + "tests/dynamic_fixtures/middleware/rails/vuln.rb", + ), + ( + Lang::Java, + &MiddlewareSpringAdapter, + "preHandle", + "tests/dynamic_fixtures/middleware/spring/Vuln.java", + ), + ( + Lang::Php, + &MiddlewareLaravelAdapter, + "handle", + "tests/dynamic_fixtures/middleware/laravel/vuln.php", + ), + ]; + let confirmed = cases + .iter() + .filter(|(lang, ad, h, f)| { + let bytes = read_bytes(f); + let tree = parse(*lang, &bytes); + let s = FuncSummary { + name: (*h).into(), + ..Default::default() + }; + ad.detect(&s, tree.root_node(), &bytes).is_some() + }) + .count(); + assert!( + confirmed * 4 >= cases.len() * 3, + "middleware adapter binding rate must be >= 75% (got {confirmed}/{})", + cases.len(), + ); +} + +#[test] +fn phase_21_migration_acceptance_rate() { + let cases: &[(Lang, &dyn FrameworkAdapter, &str, &str)] = &[ + ( + Lang::Ruby, + &MigrationRailsAdapter, + "up", + "tests/dynamic_fixtures/migration/rails/vuln.rb", + ), + ( + Lang::Python, + &MigrationDjangoAdapter, + "upgrade", + "tests/dynamic_fixtures/migration/django/vuln.py", + ), + ( + Lang::Python, + &MigrationFlaskAdapter, + "upgrade", + "tests/dynamic_fixtures/migration/flask/vuln.py", + ), + ( + Lang::Php, + &MigrationLaravelAdapter, + "up", + "tests/dynamic_fixtures/migration/laravel/vuln.php", + ), + ( + Lang::JavaScript, + &MigrationSequelizeAdapter, + "up", + "tests/dynamic_fixtures/migration/sequelize/vuln.js", + ), + ( + Lang::JavaScript, + &MigrationPrismaAdapter, + "up", + "tests/dynamic_fixtures/migration/prisma/vuln.js", + ), + ]; + let confirmed = cases + .iter() + .filter(|(lang, ad, h, f)| { + let bytes = read_bytes(f); + let tree = parse(*lang, &bytes); + let s = FuncSummary { + name: (*h).into(), + ..Default::default() + }; + ad.detect(&s, tree.root_node(), &bytes).is_some() + }) + .count(); + assert!( + confirmed * 4 >= cases.len() * 3, + "migration adapter binding rate must be >= 75% (got {confirmed}/{})", + cases.len(), + ); +}