mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
refactor(dynamic): enhance resolver detection for frameworks, refine SSA receiver validation, and expand test coverage
This commit is contained in:
parent
3027c1afa7
commit
f49211d788
38 changed files with 1198 additions and 137 deletions
|
|
@ -39,6 +39,19 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) {
|
|||
("Query".to_owned(), summary.name.clone())
|
||||
}
|
||||
|
||||
fn name_is_gqlgen_resolver(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.starts_with("Resolve") {
|
||||
return true;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
text.contains(&format!("*queryResolver) {name}("))
|
||||
|| text.contains(&format!("*mutationResolver) {name}("))
|
||||
|| text.contains(&format!("*subscriptionResolver) {name}("))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for GraphqlGqlgenAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -56,7 +69,7 @@ impl FrameworkAdapter for GraphqlGqlgenAdapter {
|
|||
) -> 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 {
|
||||
if matches_source && (name_is_gqlgen_resolver(&summary.name, file_bytes) || matches_call) {
|
||||
let (type_name, field) = extract_resolver(summary);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -100,4 +113,22 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "graphql-gqlgen");
|
||||
assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_gqlgen_file() {
|
||||
let src: &[u8] = b"package graph\n\
|
||||
import \"github.com/99designs/gqlgen/graphql\"\n\
|
||||
type queryResolver struct{}\n\
|
||||
func NormalizeID(id string) string { return id }\n";
|
||||
let tree = parse_go(src);
|
||||
let summary = FuncSummary {
|
||||
name: "NormalizeID".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
GraphqlGqlgenAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) {
|
|||
("Query".to_owned(), summary.name.clone())
|
||||
}
|
||||
|
||||
fn name_is_graphene_resolver(name: &str) -> bool {
|
||||
name.starts_with("resolve_")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for GraphqlGrapheneAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -59,7 +63,7 @@ impl FrameworkAdapter for GraphqlGrapheneAdapter {
|
|||
) -> 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 {
|
||||
if matches_source && (name_is_graphene_resolver(&summary.name) || matches_call) {
|
||||
let (type_name, field) = extract_resolver(summary);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -104,4 +108,21 @@ mod tests {
|
|||
assert_eq!(field, "user");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_graphene_file() {
|
||||
let src: &[u8] = b"import graphene\n\
|
||||
class Query(graphene.ObjectType):\n user = graphene.String()\n\
|
||||
def normalize_id(raw):\n return str(raw)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_id".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
GraphqlGrapheneAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,39 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) {
|
|||
("Query".to_owned(), summary.name.clone())
|
||||
}
|
||||
|
||||
fn name_is_juniper_resolver(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.starts_with("resolve_") {
|
||||
return true;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let needle = format!("fn {name}(");
|
||||
let mut search_from = 0;
|
||||
while let Some(rel_idx) = text[search_from..].find(&needle) {
|
||||
let fn_idx = search_from + rel_idx;
|
||||
let before = &text[..fn_idx];
|
||||
let Some(impl_idx) = before.rfind("impl ") else {
|
||||
search_from = fn_idx + needle.len();
|
||||
continue;
|
||||
};
|
||||
if before[impl_idx..].contains('}') {
|
||||
search_from = fn_idx + needle.len();
|
||||
continue;
|
||||
}
|
||||
let scope_start = before[..impl_idx]
|
||||
.rfind('}')
|
||||
.map(|idx| idx + 1)
|
||||
.unwrap_or(0);
|
||||
if before[scope_start..impl_idx].contains("#[graphql_object") {
|
||||
return true;
|
||||
}
|
||||
search_from = fn_idx + needle.len();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for GraphqlJuniperAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -54,7 +87,7 @@ impl FrameworkAdapter for GraphqlJuniperAdapter {
|
|||
) -> 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 {
|
||||
if matches_source && (name_is_juniper_resolver(&summary.name, file_bytes) || matches_call) {
|
||||
let (type_name, field) = extract_resolver(summary);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -98,4 +131,39 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "graphql-juniper");
|
||||
assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_juniper_file() {
|
||||
let src: &[u8] = b"use juniper::RootNode;\n\
|
||||
pub fn normalize_id(id: &str) -> String { id.to_string() }\n";
|
||||
let tree = parse_rust(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_id".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
GraphqlJuniperAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_free_helper_next_to_graphql_object_impl() {
|
||||
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\
|
||||
pub fn normalize_id(id: &str) -> String { id.to_string() }\n";
|
||||
let tree = parse_rust(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_id".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
GraphqlJuniperAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ fn extract_resolver(summary: &FuncSummary) -> (String, String) {
|
|||
("Node".to_owned(), summary.name.clone())
|
||||
}
|
||||
|
||||
fn name_is_relay_resolver(name: &str) -> bool {
|
||||
name.starts_with("resolve") || name.ends_with("Resolver")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for GraphqlRelayAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -66,7 +70,7 @@ impl FrameworkAdapter for GraphqlRelayAdapter {
|
|||
) -> 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 {
|
||||
if matches_source && (name_is_relay_resolver(&summary.name) || matches_call) {
|
||||
let (type_name, field) = extract_resolver(summary);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -108,4 +112,20 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "graphql-relay");
|
||||
assert!(matches!(binding.kind, EntryKind::GraphQLResolver { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_relay_file() {
|
||||
let src: &[u8] = b"const { nodeDefinitions } = require('graphql-relay');\n\
|
||||
function normalizeId(id) { return String(id); }\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalizeId".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
GraphqlRelayAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,6 @@ 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"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_has_middleware_shape(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"django.utils.deprecation",
|
||||
|
|
@ -38,6 +30,31 @@ fn source_has_middleware_shape(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn name_is_django_middleware_entry(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"__call__" | "process_request" | "process_response" | "process_view" | "process_exception"
|
||||
)
|
||||
}
|
||||
|
||||
fn name_wraps_django_middleware(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let needle = format!("def {name}(");
|
||||
let Some(start) = text.find(&needle) else {
|
||||
return false;
|
||||
};
|
||||
let rest = &text[start..];
|
||||
let end = rest.find("\ndef ").unwrap_or(rest.len());
|
||||
let body = &rest[..end];
|
||||
body.contains("Middleware(") && body.contains("return ")
|
||||
}
|
||||
|
||||
fn looks_like_settings_module(file_bytes: &[u8]) -> bool {
|
||||
// Heuristic: settings.py declares MIDDLEWARE / INSTALLED_APPS / DATABASES at
|
||||
// module scope. A real middleware module declares none of these (it carries
|
||||
|
|
@ -75,9 +92,11 @@ impl FrameworkAdapter for MiddlewareDjangoAdapter {
|
|||
if looks_like_settings_module(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_django_middleware);
|
||||
let matches_source = source_has_middleware_shape(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
if matches_source
|
||||
&& (name_is_django_middleware_entry(&summary.name)
|
||||
|| name_wraps_django_middleware(&summary.name, file_bytes))
|
||||
{
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Middleware {
|
||||
|
|
@ -136,4 +155,38 @@ mod tests {
|
|||
"settings.py-shaped module must not bind as middleware",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_django_middleware_file() {
|
||||
let src: &[u8] = b"from django.utils.deprecation import MiddlewareMixin\n\
|
||||
class AuditMiddleware(MiddlewareMixin):\n def process_request(self, request):\n pass\n\
|
||||
def normalize_request(request):\n return request\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_request".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MiddlewareDjangoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_module_level_factory_wrapper() {
|
||||
let src: &[u8] = b"from django.utils.deprecation import MiddlewareMixin\n\
|
||||
class AuditMiddleware(MiddlewareMixin):\n def __call__(self, request):\n pass\n\
|
||||
def audit(get_response):\n return AuditMiddleware(get_response)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "audit".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MiddlewareDjangoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ impl FrameworkAdapter for MiddlewareLaravelAdapter {
|
|||
let has_shape = source_has_middleware_shape(file_bytes);
|
||||
let name_matches = name_is_middleware_entry(&summary.name);
|
||||
let body_mounts_middleware =
|
||||
super::any_callee_matches(summary, callee_is_laravel_middleware);
|
||||
has_shape && super::any_callee_matches(summary, callee_is_laravel_middleware);
|
||||
let binds = (name_matches && has_shape) || body_mounts_middleware;
|
||||
if !binds {
|
||||
return None;
|
||||
|
|
@ -118,4 +118,25 @@ mod tests {
|
|||
"controller method must not bind as middleware just because the file imports Request",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_bind_with_middleware_call_without_contract_shape() {
|
||||
let src: &[u8] = b"<?php\nclass Bootstrapper {\n public function configure($app) { return $app->withMiddleware([]); }\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let mut summary = FuncSummary {
|
||||
name: "configure".into(),
|
||||
..Default::default()
|
||||
};
|
||||
summary.callees.push(crate::summary::CalleeSite {
|
||||
name: "app.withMiddleware".to_owned(),
|
||||
receiver: Some("app".to_owned()),
|
||||
ordinal: 0,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(
|
||||
MiddlewareLaravelAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,14 +14,6 @@ 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",
|
||||
|
|
@ -36,6 +28,13 @@ fn source_imports_spring_middleware(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn name_is_spring_middleware_entry(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"preHandle" | "postHandle" | "afterCompletion" | "doFilter" | "addInterceptors"
|
||||
)
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MiddlewareSpringAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -51,9 +50,9 @@ impl FrameworkAdapter for MiddlewareSpringAdapter {
|
|||
_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 {
|
||||
if source_imports_spring_middleware(file_bytes)
|
||||
&& name_is_spring_middleware_entry(&summary.name)
|
||||
{
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Middleware {
|
||||
|
|
@ -95,4 +94,19 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "middleware-spring");
|
||||
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_spring_middleware_file() {
|
||||
let src: &[u8] = b"public class AuditInterceptor implements HandlerInterceptor {\n public boolean preHandle(Object req, Object res, Object handler) { return true; }\n public String normalize(String payload) { return payload; }\n}\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MiddlewareSpringAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,20 +12,6 @@ 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",
|
||||
|
|
@ -56,6 +42,13 @@ fn extract_version(file_bytes: &[u8]) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
fn name_is_django_migration_entry(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"Migration" | "upgrade" | "downgrade" | "forwards" | "backwards"
|
||||
)
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MigrationDjangoAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -71,9 +64,8 @@ impl FrameworkAdapter for MigrationDjangoAdapter {
|
|||
_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 {
|
||||
if matches_source && name_is_django_migration_entry(&summary.name) {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Migration {
|
||||
|
|
@ -116,4 +108,21 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "migration-django");
|
||||
assert!(matches!(binding.kind, EntryKind::Migration { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_django_migration_file() {
|
||||
let src: &[u8] = b"from django.db import migrations\n\
|
||||
class Migration(migrations.Migration):\n operations = [migrations.CreateModel(name='User', fields=[])]\n\
|
||||
def normalize_name(name):\n return str(name)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_name".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MigrationDjangoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,20 +13,6 @@ 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",
|
||||
|
|
@ -57,6 +43,10 @@ fn extract_version(file_bytes: &[u8]) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
fn name_is_flask_migration_entry(name: &str) -> bool {
|
||||
matches!(name, "upgrade" | "downgrade")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MigrationFlaskAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -72,9 +62,8 @@ impl FrameworkAdapter for MigrationFlaskAdapter {
|
|||
_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 {
|
||||
if matches_source && name_is_flask_migration_entry(&summary.name) {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Migration {
|
||||
|
|
@ -119,4 +108,21 @@ mod tests {
|
|||
assert_eq!(version.as_deref(), Some("abc123"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_alembic_file() {
|
||||
let src: &[u8] = b"from alembic import op\nrevision = 'abc123'\n\
|
||||
def upgrade():\n op.create_table('users')\n\
|
||||
def normalize_name(name):\n return str(name)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_name".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MigrationFlaskAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,6 @@ 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')",
|
||||
|
|
@ -37,6 +29,10 @@ fn source_imports_sequelize_migration(file_bytes: &[u8]) -> bool {
|
|||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn name_is_sequelize_migration_entry(name: &str) -> bool {
|
||||
matches!(name, "up" | "down")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MigrationSequelizeAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -52,9 +48,8 @@ impl FrameworkAdapter for MigrationSequelizeAdapter {
|
|||
_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 {
|
||||
if matches_source && name_is_sequelize_migration_entry(&summary.name) {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Migration { version: None },
|
||||
|
|
@ -94,4 +89,19 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "migration-sequelize");
|
||||
assert!(matches!(binding.kind, EntryKind::Migration { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_sequelize_migration_file() {
|
||||
let src: &[u8] = b"module.exports = {\n async up(queryInterface, Sequelize) { await queryInterface.createTable('users', {}); },\n};\nfunction normalizeName(name) { return String(name); }\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalizeName".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MigrationSequelizeAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct ScheduledCeleryAdapter;
|
||||
|
|
@ -53,6 +54,36 @@ fn extract_schedule(file_bytes: &[u8]) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
fn name_registered_as_celery_task(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let needle = format!("def {name}(");
|
||||
let Some(def_idx) = text.find(&needle) else {
|
||||
return false;
|
||||
};
|
||||
let before = &text[..def_idx];
|
||||
let since_prev_def = before
|
||||
.rfind("\ndef ")
|
||||
.map(|idx| &before[idx + 1..])
|
||||
.unwrap_or(before);
|
||||
since_prev_def.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.contains("@shared_task")
|
||||
|| trimmed.contains("@app.task")
|
||||
|| trimmed.contains("@celery.task")
|
||||
})
|
||||
}
|
||||
|
||||
fn typed_container_allows_celery(container: &str) -> bool {
|
||||
let lc = container.to_ascii_lowercase();
|
||||
lc.contains("celery") || lc.contains("task") || lc.contains("signature")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for ScheduledCeleryAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -65,31 +96,59 @@ impl FrameworkAdapter for ScheduledCeleryAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
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
|
||||
}
|
||||
detect_celery(summary, None, ast, file_bytes)
|
||||
}
|
||||
|
||||
fn detect_with_context(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_celery(summary, ssa_summary, ast, file_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_celery(
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_celery(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let registered = name_registered_as_celery_task(&summary.name, file_bytes);
|
||||
let celery_call = super::any_callee_matches(summary, callee_is_celery)
|
||||
&& super::typed_receiver_facts_allow(
|
||||
summary,
|
||||
ssa_summary,
|
||||
callee_is_celery,
|
||||
typed_container_allows_celery,
|
||||
);
|
||||
if !(registered || celery_call) {
|
||||
return None;
|
||||
}
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::summary::CalleeSite;
|
||||
|
||||
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
|
|
@ -114,4 +173,46 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "scheduled-celery");
|
||||
assert!(matches!(binding.kind, EntryKind::ScheduledJob { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unregistered_helper_in_celery_file() {
|
||||
let src: &[u8] = b"from celery import shared_task\n\
|
||||
@shared_task\n\
|
||||
def tick(payload):\n print(payload)\n\
|
||||
def format_payload(payload):\n return str(payload)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "format_payload".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
ScheduledCeleryAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssa_receiver_type_rejects_non_celery_delay_collision() {
|
||||
let src: &[u8] = b"from celery import shared_task\n\
|
||||
def enqueue(payload):\n mailer.delay(payload)\n";
|
||||
let tree = parse_python(src);
|
||||
let mut summary = FuncSummary {
|
||||
name: "enqueue".into(),
|
||||
..Default::default()
|
||||
};
|
||||
summary.callees.push(CalleeSite {
|
||||
name: "mailer.delay".to_owned(),
|
||||
receiver: Some("mailer".to_owned()),
|
||||
ordinal: 0,
|
||||
..Default::default()
|
||||
});
|
||||
let mut ssa = SsaFuncSummary::default();
|
||||
ssa.typed_call_receivers.push((0, "Mailer".to_owned()));
|
||||
assert!(
|
||||
ScheduledCeleryAdapter
|
||||
.detect_with_context(&summary, Some(&ssa), tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
||||
pub struct ScheduledQuartzAdapter;
|
||||
|
|
@ -53,6 +54,46 @@ fn extract_schedule(file_bytes: &[u8]) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
fn name_is_quartz_entry(name: &str) -> bool {
|
||||
name == "execute"
|
||||
}
|
||||
|
||||
fn name_annotated_as_scheduled(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
for needle in [
|
||||
format!("void {name}("),
|
||||
format!("public void {name}("),
|
||||
format!("private void {name}("),
|
||||
format!("protected void {name}("),
|
||||
] {
|
||||
if let Some(idx) = text.find(&needle) {
|
||||
let before = &text[..idx];
|
||||
let since_prev_method = before
|
||||
.rfind("\n ")
|
||||
.map(|prev| &before[prev + 1..])
|
||||
.unwrap_or(before);
|
||||
if since_prev_method.contains("@Scheduled") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn typed_container_allows_quartz(container: &str) -> bool {
|
||||
let lc = container.to_ascii_lowercase();
|
||||
lc.contains("quartz")
|
||||
|| lc.contains("scheduler")
|
||||
|| lc.contains("jobbuilder")
|
||||
|| lc.contains("triggerbuilder")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for ScheduledQuartzAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -65,31 +106,60 @@ impl FrameworkAdapter for ScheduledQuartzAdapter {
|
|||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
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
|
||||
}
|
||||
detect_quartz(summary, None, ast, file_bytes)
|
||||
}
|
||||
|
||||
fn detect_with_context(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_quartz(summary, ssa_summary, ast, file_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_quartz(
|
||||
summary: &FuncSummary,
|
||||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_quartz(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let job_entry = name_is_quartz_entry(&summary.name);
|
||||
let scheduled_method = name_annotated_as_scheduled(&summary.name, file_bytes);
|
||||
let quartz_call = super::any_callee_matches(summary, callee_is_quartz)
|
||||
&& super::typed_receiver_facts_allow(
|
||||
summary,
|
||||
ssa_summary,
|
||||
callee_is_quartz,
|
||||
typed_container_allows_quartz,
|
||||
);
|
||||
if !(job_entry || scheduled_method || quartz_call) {
|
||||
return None;
|
||||
}
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::summary::CalleeSite;
|
||||
|
||||
fn parse_java(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
|
|
@ -132,4 +202,49 @@ mod tests {
|
|||
assert_eq!(schedule.as_deref(), Some("0 0 12 * * ?"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_quartz_file() {
|
||||
let src: &[u8] = b"import org.quartz.Job;\n\
|
||||
public class TickJob implements Job {\n\
|
||||
public void execute(JobExecutionContext ctx) { }\n\
|
||||
public String format(String payload) { return payload; }\n\
|
||||
}\n";
|
||||
let tree = parse_java(src);
|
||||
let summary = FuncSummary {
|
||||
name: "format".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
ScheduledQuartzAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssa_receiver_type_rejects_non_quartz_schedule_collision() {
|
||||
let src: &[u8] = b"import org.quartz.Job;\n\
|
||||
public class TickJob implements Job {\n\
|
||||
public void enqueue(Object payload) { queue.scheduleJob(payload); }\n\
|
||||
}\n";
|
||||
let tree = parse_java(src);
|
||||
let mut summary = FuncSummary {
|
||||
name: "enqueue".into(),
|
||||
..Default::default()
|
||||
};
|
||||
summary.callees.push(CalleeSite {
|
||||
name: "queue.scheduleJob".to_owned(),
|
||||
receiver: Some("queue".to_owned()),
|
||||
ordinal: 0,
|
||||
..Default::default()
|
||||
});
|
||||
let mut ssa = SsaFuncSummary::default();
|
||||
ssa.typed_call_receivers.push((0, "MailQueue".to_owned()));
|
||||
assert!(
|
||||
ScheduledQuartzAdapter
|
||||
.detect_with_context(&summary, Some(&ssa), tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,6 @@ 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",
|
||||
|
|
@ -54,6 +46,10 @@ fn extract_path(file_bytes: &[u8]) -> String {
|
|||
"/cable".to_owned()
|
||||
}
|
||||
|
||||
fn name_is_actioncable_entry(name: &str) -> bool {
|
||||
matches!(name, "receive" | "subscribed" | "unsubscribed")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for WebsocketActionCableAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -69,9 +65,7 @@ impl FrameworkAdapter for WebsocketActionCableAdapter {
|
|||
_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 {
|
||||
if source_imports_actioncable(file_bytes) && name_is_actioncable_entry(&summary.name) {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::WebSocket {
|
||||
|
|
@ -115,4 +109,19 @@ mod tests {
|
|||
assert_eq!(path, "chat_room");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_actioncable_file() {
|
||||
let src: &[u8] = b"class ChatChannel < ApplicationCable::Channel\n def receive(data)\n end\n def normalize(data)\n data.to_s\n end\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
WebsocketActionCableAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,6 @@ 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",
|
||||
|
|
@ -49,6 +41,13 @@ fn extract_path(file_bytes: &[u8]) -> String {
|
|||
"/ws/".to_owned()
|
||||
}
|
||||
|
||||
fn name_is_channels_entry(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"receive" | "receive_json" | "connect" | "disconnect" | "websocket_receive"
|
||||
)
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for WebsocketChannelsAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -64,9 +63,7 @@ impl FrameworkAdapter for WebsocketChannelsAdapter {
|
|||
_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 {
|
||||
if source_imports_channels(file_bytes) && name_is_channels_entry(&summary.name) {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::WebSocket {
|
||||
|
|
@ -109,4 +106,21 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "websocket-channels");
|
||||
assert!(matches!(binding.kind, EntryKind::WebSocket { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_channels_file() {
|
||||
let src: &[u8] = b"from channels.generic.websocket import WebsocketConsumer\n\
|
||||
class ChatConsumer(WebsocketConsumer):\n def receive(self, text_data=None):\n pass\n\
|
||||
def normalize_frame(text_data):\n return str(text_data)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize_frame".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
WebsocketChannelsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,6 @@ 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",
|
||||
|
|
@ -49,6 +41,29 @@ fn extract_path(file_bytes: &[u8]) -> String {
|
|||
"/".to_owned()
|
||||
}
|
||||
|
||||
fn name_registered_as_socketio_event(name: &str, file_bytes: &[u8]) -> bool {
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let text = match std::str::from_utf8(file_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let def_needle = format!("def {name}(");
|
||||
let Some(def_idx) = text.find(&def_needle) else {
|
||||
return false;
|
||||
};
|
||||
let before = &text[..def_idx];
|
||||
let since_prev_def = before
|
||||
.rfind("\ndef ")
|
||||
.map(|idx| &before[idx + 1..])
|
||||
.unwrap_or(before);
|
||||
since_prev_def.contains("@sio.event")
|
||||
|| since_prev_def.contains("@socketio.event")
|
||||
|| since_prev_def.contains(&format!("@sio.on('{name}'"))
|
||||
|| since_prev_def.contains(&format!("@sio.on(\"{name}\""))
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for WebsocketSocketIoAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -64,9 +79,8 @@ impl FrameworkAdapter for WebsocketSocketIoAdapter {
|
|||
_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 {
|
||||
let registered = name_registered_as_socketio_event(&summary.name, file_bytes);
|
||||
if source_imports_socketio(file_bytes) && registered {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::WebSocket {
|
||||
|
|
@ -113,4 +127,23 @@ mod tests {
|
|||
assert_eq!(path, "message");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unrelated_helper_in_socketio_file() {
|
||||
let src: &[u8] = b"import socketio\n\
|
||||
sio = socketio.Server()\n\
|
||||
@sio.on('message')\n\
|
||||
def message(sid, data):\n pass\n\
|
||||
def normalize(data):\n return str(data)\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "normalize".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
WebsocketSocketIoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue