[pitboss] phase 21: Track M.3 — ScheduledJob + GraphQLResolver + WebSocket + Middleware + Migration

This commit is contained in:
pitboss 2026-05-20 18:05:31 -05:00
parent 00b0fbaea9
commit f9bd51c024
84 changed files with 5898 additions and 40 deletions

View file

@ -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<FrameworkBinding> {
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");
}
}
}

View file

@ -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<FrameworkBinding> {
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 { .. }));
}
}

View file

@ -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_<field>` 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<FrameworkBinding> {
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");
}
}
}

View file

@ -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<FrameworkBinding> {
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 { .. }));
}
}

View file

@ -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<FrameworkBinding> {
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 { .. }));
}
}

View file

@ -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<FrameworkBinding> {
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 { .. }));
}
}

View file

@ -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<FrameworkBinding> {
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");
}
}
}

View file

@ -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<FrameworkBinding> {
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"<?php\nuse Illuminate\\Http\\Request;\nclass Audit {\n public function handle($request, Closure $next) { return $next($request); }\n}\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "handle".into(),
..Default::default()
};
let binding = MiddlewareLaravelAdapter
.detect(&summary, tree.root_node(), src)
.expect("laravel middleware binds");
assert_eq!(binding.adapter, "middleware-laravel");
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
}
}

View file

@ -0,0 +1,98 @@
//! Phase 21 (Track M.3) — Rack / Rails middleware adapter (Ruby).
//!
//! Fires when the surrounding source defines a Rack-shaped middleware
//! (`def call(env)`) or registers a Rails before-action callback.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct MiddlewareRailsAdapter;
const ADAPTER_NAME: &str = "middleware-rails";
fn callee_is_rails_middleware(name: &str) -> 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<FrameworkBinding> {
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 { .. }));
}
}

View file

@ -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<FrameworkBinding> {
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 { .. }));
}
}

View file

@ -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<String> {
// 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<FrameworkBinding> {
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 { .. }));
}
}

View file

@ -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<String> {
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<FrameworkBinding> {
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"));
}
}
}

View file

@ -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<FrameworkBinding> {
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"<?php\nuse Illuminate\\Database\\Migrations\\Migration;\nclass AddUsers extends Migration { public function up() { Schema::create('users', function($t){}); } }\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "up".into(),
..Default::default()
};
let binding = MigrationLaravelAdapter
.detect(&summary, tree.root_node(), src)
.expect("laravel migration binds");
assert_eq!(binding.adapter, "migration-laravel");
assert!(matches!(binding.kind, EntryKind::Migration { .. }));
}
}

View file

@ -0,0 +1,105 @@
//! Phase 21 (Track M.3) — Prisma migration adapter (JS / TS).
//!
//! Prisma migrations are SQL files generated by `prisma migrate dev`
//! plus a TS / JS seed script that calls `prisma.$executeRaw`. Fires
//! when the surrounding source imports `@prisma/client` and the
//! function body invokes one of the raw-execution callees.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct MigrationPrismaAdapter;
const ADAPTER_NAME: &str = "migration-prisma";
fn callee_is_prisma_migration(name: &str) -> 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<FrameworkBinding> {
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 { .. }));
}
}

View file

@ -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<String> {
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<FrameworkBinding> {
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"));
}
}
}

View file

@ -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<FrameworkBinding> {
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 { .. }));
}
}

View file

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

View file

@ -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<String> {
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<FrameworkBinding> {
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 { .. }));
}
}

View file

@ -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<String> {
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<FrameworkBinding> {
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());
}
}

View file

@ -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<String> {
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<FrameworkBinding> {
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 * * ?"));
}
}
}

View file

@ -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<String> {
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<FrameworkBinding> {
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 { .. }));
}
}

View file

@ -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<FrameworkBinding> {
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");
}
}
}

View file

@ -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<FrameworkBinding> {
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 { .. }));
}
}

View file

@ -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<FrameworkBinding> {
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");
}
}
}

View file

@ -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<FrameworkBinding> {
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");
}
}
}