diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index a8debb544..6da74d478 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -870,6 +870,139 @@ bool cbm_service_pattern_is_http_route_literal(const char *literal, const char * return true; } +/* ── GraphQL operation identity (#598) ─────────────────────────── */ + +static bool gql_name_start(unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; +} + +static bool gql_name_char(unsigned char c) { + return gql_name_start(c) || (c >= '0' && c <= '9'); +} + +/* Skip one lexical unit whose braces are not structure: a # comment, a "..." + * or """...""" string, or a JS ${...} template interpolation. Returns p + * unchanged when p starts none of them. */ +static const char *gql_skip_opaque(const char *p) { + if (*p == '#') { + while (*p && *p != '\n' && *p != '\r') { + p++; + } + return p; + } + if (strncmp(p, "\"\"\"", 3) == 0) { + const char *end = strstr(p + 3, "\"\"\""); + return end ? end + 3 : p + strlen(p); + } + if (*p == '"') { + p++; + while (*p && *p != '"') { + p += (*p == '\\' && p[1]) ? 2 : 1; + } + return *p ? p + 1 : p; + } + if (p[0] == '$' && p[1] == '{') { + int depth = 1; + p += 2; + while (*p && depth > 0) { + depth += (*p == '{') - (*p == '}'); + p++; + } + return p; + } + return p; +} + +/* Skip GraphQL "ignored tokens" (whitespace, commas, comments). */ +static const char *gql_skip_ignored(const char *p) { + for (;;) { + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') { + p++; + } + if (*p != '#') { + return p; + } + p = gql_skip_opaque(p); + } +} + +/* Copy the Name token at p (bounded). Returns false when p is not a Name. */ +static bool gql_copy_name(const char *p, char *name_buf, size_t name_sz) { + if (!gql_name_start((unsigned char)*p) || !name_buf || name_sz == 0) { + return false; + } + size_t n = 0; + while (gql_name_char((unsigned char)p[n]) && n + 1 < name_sz) { + name_buf[n] = p[n]; + n++; + } + name_buf[n] = '\0'; + return true; +} + +static const char *gql_operation_keyword(const char *tok, size_t len) { + static const char *const kws[] = {"query", "mutation", "subscription", NULL}; + for (int i = 0; kws[i]; i++) { + if (strlen(kws[i]) == len && strncmp(tok, kws[i], len) == 0) { + return kws[i]; + } + } + return NULL; +} + +bool cbm_service_pattern_graphql_operation(const char *doc, const char **op_type, char *name_buf, + size_t name_sz) { + *op_type = "operation"; + if (name_buf && name_sz > 0) { + name_buf[0] = '\0'; + } + if (!doc) { + return false; + } + int depth = 0; + bool in_fragment = false; + const char *p = doc; + while (*p) { + const char *q = gql_skip_opaque(p); + if (q != p) { + p = q; + continue; + } + unsigned char c = (unsigned char)*p; + if (c == '{' && depth == 0 && !in_fragment) { + *op_type = "query"; /* `{ ... }` shorthand is an anonymous query */ + return false; + } + if (c == '{' || c == '}') { + depth += (c == '{') ? 1 : -1; + depth = depth < 0 ? 0 : depth; + in_fragment = in_fragment && depth > 0; + p++; + continue; + } + if (depth > 0 || in_fragment || !gql_name_start(c)) { + p++; + continue; + } + const char *tok = p; + while (gql_name_char((unsigned char)*p)) { + p++; + } + size_t len = (size_t)(p - tok); + if (len == strlen("fragment") && strncmp(tok, "fragment", len) == 0) { + in_fragment = true; + continue; + } + const char *kw = gql_operation_keyword(tok, len); + if (!kw) { + return false; /* not a GraphQL document (URL, dict key, variable ...) */ + } + *op_type = kw; + return gql_copy_name(gql_skip_ignored(p), name_buf, name_sz); + } + return false; +} + /* ── Public API ────────────────────────────────────────────────── */ /* Per-worker TLS cache of cbm_service_pattern_match results. diff --git a/internal/cbm/service_patterns.h b/internal/cbm/service_patterns.h index d9a93c251..020ab429d 100644 --- a/internal/cbm/service_patterns.h +++ b/internal/cbm/service_patterns.h @@ -12,6 +12,7 @@ #define CBM_SERVICE_PATTERNS_H #include +#include /* Edge type returned by pattern match. */ typedef enum { @@ -77,4 +78,17 @@ const char *cbm_service_pattern_route_method(const char *callee_name); * Returns NULL if not an async pattern. */ const char *cbm_service_pattern_broker(const char *resolved_qn); +/* GraphQL operation identity of a client call argument (#598). + * Scans `doc` as a GraphQL document (skipping comments, strings, `${...}` + * template interpolations and leading fragment definitions) and reports the + * first operation: *op_type is "query", "mutation" or "subscription" ("query" + * for the `{ ... }` shorthand). Returns true and writes the operation name to + * name_buf when the operation is named. Returns false for an anonymous + * operation, or with *op_type = "operation" when `doc` is not a GraphQL + * document (NULL, a URL, a bare word such as a dict key ...). The operation + * text itself is never copied, so callers can key Routes on a bounded + * identity. */ +bool cbm_service_pattern_graphql_operation(const char *doc, const char **op_type, char *name_buf, + size_t name_sz); + #endif /* CBM_SERVICE_PATTERNS_H */ diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index c6e8e0906..c141bd451 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2447,40 +2447,43 @@ static void emit_grpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, cons cbm_gbuf_insert_edge(gbuf, source->id, route_id, "GRPC_CALLS", props); } -/* Emit GRAPHQL_CALLS edge. Extract operation from first string arg if available. */ +/* Emit GRAPHQL_CALLS edge. The Route is keyed by the operation NAME only + * (#598), never by the operation text: + * named operation -> __graphql__ name "" + * anonymous query/mutation/ -> __graphql____anonymous name "(anonymous )" + * subscription (incl. the `{ ... }` query shorthand) + * no GraphQL document -> __graphql__operation__anonymous name "(anonymous operation)" + * (URL, variable, missing string argument) + * Keys are bounded by the GraphQL Name grammar and deterministic. */ static void emit_graphql_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, const cbm_resolution_t *res) { - const char *op = call->first_string_arg; - if (!op || !op[0]) { - op = call->callee_name; - } - /* Try to extract a query/mutation name from the operation string */ - char op_name[CBM_SZ_256]; - snprintf(op_name, sizeof(op_name), "%s", op); - /* Trim leading whitespace and "query "/"mutation " prefix */ - const char *p = op_name; - while (*p == ' ' || *p == '\t' || *p == '\n') { - p++; - } - if (strncmp(p, "query ", CBM_SZ_6) == 0) { - p += CBM_SZ_6; - } else if (strncmp(p, "mutation ", CBM_SZ_8) == 0) { - p += CBM_SZ_8; + const char *op_type = "operation"; + char op_name[CBM_SZ_128]; + bool named = cbm_service_pattern_graphql_operation(call->first_string_arg, &op_type, op_name, + sizeof(op_name)); + + char route_name[CBM_SZ_256]; + char route_qn[CBM_SZ_256]; + if (named) { + snprintf(route_name, sizeof(route_name), "%s", op_name); + snprintf(route_qn, sizeof(route_qn), "__graphql__%s", op_name); + } else { + snprintf(route_name, sizeof(route_name), "(anonymous %s)", op_type); + snprintf(route_qn, sizeof(route_qn), "__graphql__%s__anonymous", op_type); } - char route_qn[CBM_SZ_512]; - snprintf(route_qn, sizeof(route_qn), "__graphql__%s", p); - - int64_t route_id = - cbm_gbuf_upsert_node(gbuf, "Route", p, route_qn, "", 0, 0, "{\"source\":\"graphql\"}"); + int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", route_name, route_qn, "", 0, 0, + "{\"source\":\"graphql\"}"); char esc_c[CBM_SZ_256]; char esc_op[CBM_SZ_512]; cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name); - cbm_json_escape(esc_op, sizeof(esc_op), p); + cbm_json_escape(esc_op, sizeof(esc_op), route_name); char props[CBM_SZ_1K]; - snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"operation\":\"%s\",\"confidence\":%.2f}", - esc_c, esc_op, res->confidence); + snprintf(props, sizeof(props), + "{\"callee\":\"%s\",\"operation\":\"%s\",\"operation_type\":\"%s\"," + "\"confidence\":%.2f}", + esc_c, esc_op, op_type, res->confidence); cbm_gbuf_insert_edge(gbuf, source->id, route_id, "GRAPHQL_CALLS", props); } diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index 9fb6bd87c..5ed449b8d 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -43,6 +43,7 @@ #include "test_framework.h" #include "test_helpers.h" #include "cbm.h" +#include "service_patterns.h" #include #include #include @@ -244,6 +245,136 @@ static cbm_store_t *et_index_parallel(EtProj *lp, const EtFile *meaningful, int return et_index_files(lp, files, n); } +/* #598: a call that resolves to a GraphQL client used to name its Route after + * the WHOLE operation text (minus a leading "query "/"mutation "), so an + * anonymous `query { ... }` became one long Route and even a named operation + * carried its variables and selection set in the name. GraphQL Routes are now + * keyed by operation name only; anonymous operations share one bounded key per + * operation type. The Route set is asserted exactly (QN + name). Parallel path + * only: GRAPHQL_CALLS is emitted by pass_parallel.c. */ +typedef struct { + const char *qn; + const char *name; +} EtGqlRoute; + +static int et_graphql_routes_exact(const EtFile *files, int nfiles, const EtGqlRoute *want, + int nwant) { + EtProj lp; + cbm_store_t *store = et_index_parallel(&lp, files, nfiles); + cbm_node_t *nodes = NULL; + int node_count = 0; + int ok = store != NULL; + int got = 0; + if (!store || cbm_store_find_nodes_by_label(store, lp.project, "Route", &nodes, &node_count) != + CBM_STORE_OK) { + ok = 0; + } + for (int ni = 0; nodes && ni < node_count; ni++) { + const char *qn = nodes[ni].qualified_name ? nodes[ni].qualified_name : ""; + const char *name = nodes[ni].name ? nodes[ni].name : ""; + if (strncmp(qn, "__graphql__", 11) != 0) { + continue; + } + got++; + int matched = 0; + for (int wi = 0; wi < nwant; wi++) { + if (strcmp(qn, want[wi].qn) == 0 && strcmp(name, want[wi].name) == 0) { + matched = 1; + break; + } + } + /* Operation text (selection sets, variables) must never leak into a key. */ + if (!matched || strchr(qn, '{') || strchr(name, '{') || strlen(qn) > 160) { + fprintf(stderr, " [ET-GQL] unexpected Route qn='%s' name='%s'\n", qn, name); + ok = 0; + } + } + if (got != nwant) { + fprintf(stderr, " [ET-GQL] FAIL graphql routes expected=%d actual=%d\n", nwant, got); + ok = 0; + } + int edges = store ? cbm_store_count_edges_by_type(store, lp.project, "GRAPHQL_CALLS") : -1; + if (edges < nwant) { + fprintf(stderr, " [ET-GQL] FAIL GRAPHQL_CALLS=%d expected>=%d\n", edges, nwant); + ok = 0; + } + cbm_store_free_nodes(nodes, node_count); + et_cleanup(&lp, store); + return ok; +} + +TEST(graphql_route_keyed_by_operation_name_issue598) { + static const EtFile f[] = { + {"graphql/client.py", "def gql(query):\n return query\n"}, + {"api/ops.py", + "from graphql.client import gql\n\n\n" + "def get_user():\n" + " return gql('query GetUser($id: ID!) { user(id: $id) { name } }')\n\n\n" + "def update_user():\n" + " return gql('mutation UpdateUser($id: ID!) { updateUser(id: $id) { id } }')\n\n\n" + "def list_users():\n" + " return gql('fragment U on User { id } query ListUsers { users { ...U } }')\n\n\n" + "def on_event():\n" + " return gql('subscription OnEvent { event { id } }')\n\n\n" + "def products():\n" + " return gql('query { products(search: \"x\") { items { sku } } }')\n\n\n" + "def orders():\n" + " return gql('{ orders { id total } }')\n\n\n" + "def logout():\n" + " return gql('mutation { logout }')\n"}}; + static const EtGqlRoute want[] = { + {"__graphql__GetUser", "GetUser"}, + {"__graphql__UpdateUser", "UpdateUser"}, + {"__graphql__ListUsers", "ListUsers"}, + {"__graphql__OnEvent", "OnEvent"}, + /* products + orders: `query { }` and the `{ }` shorthand are both + * anonymous queries and share one key. */ + {"__graphql__query__anonymous", "(anonymous query)"}, + {"__graphql__mutation__anonymous", "(anonymous mutation)"}, + }; + ASSERT_TRUE(et_graphql_routes_exact(f, (int)(sizeof(f) / sizeof(f[0])), want, + (int)(sizeof(want) / sizeof(want[0])))); + PASS(); +} + +/* #598 parser edges the end-to-end fixture cannot reach through one language's + * string extraction: comments/strings/interpolations that contain braces, a + * bare word, non-document arguments and name truncation. */ +TEST(graphql_operation_identity_parser_issue598) { + const char *type = NULL; + char name[16]; + static const struct { + const char *doc; + int named; + const char *type; + const char *name; + } cases[] = { + {"# fetch { all }\nquery Q1 { a }", 1, "query", "Q1"}, + {"${UserFields} query WithFrag($id: ID) { u { ...UserFields } }", 1, "query", "WithFrag"}, + {"fragment F on T @dir(x: \"{\") { a } mutation M2 { b }", 1, "mutation", "M2"}, + {"\"\"\"doc { brace\"\"\" subscription S3 { c }", 1, "subscription", "S3"}, + {" query \n ( $x: Int ) { a }", 0, "query", ""}, + {" { viewer { id } }", 0, "query", ""}, + {"query GetUserByIdentifier { a }", 1, "query", "GetUserByIdenti"}, /* bounded */ + {"data", 0, "operation", ""}, /* a bare word is not evidence of an operation */ + {"https://api.example.com/graphql", 0, "operation", ""}, + {"/graphql", 0, "operation", ""}, + {"", 0, "operation", ""}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + bool named = cbm_service_pattern_graphql_operation(cases[i].doc, &type, name, sizeof(name)); + if ((int)named != cases[i].named || strcmp(type, cases[i].type) != 0 || + strcmp(name, cases[i].name) != 0) { + fprintf(stderr, " [ET-GQL] case %zu '%s' -> named=%d type=%s name=%s\n", i, + cases[i].doc, (int)named, type, name); + ASSERT_TRUE(0); + } + } + ASSERT_FALSE(cbm_service_pattern_graphql_operation(NULL, &type, name, sizeof(name))); + ASSERT_STR_EQ(type, "operation"); + PASS(); +} + /* #1085: count CALLS edges whose target node has `name`, indexing via the * PARALLEL path (et_index_parallel pads to >50 files). The per-file TS resolver * used to treat a raw tsconfig alias as a confident target. That false row made @@ -1982,6 +2113,8 @@ TEST(routes_express_get_survives_xlang_guard_parallel) { SUITE(edge_types_probe) { /* HANDLES — route→handler across web frameworks */ RUN_TEST(calls_jsx_component_via_tsconfig_alias_parallel_issue1085); + RUN_TEST(graphql_route_keyed_by_operation_name_issue598); + RUN_TEST(graphql_operation_identity_parser_issue598); RUN_TEST(handles_flask_python); RUN_TEST(handles_fastapi_python); RUN_TEST(handles_drf_action_python);