diff --git a/README.md b/README.md index 8ff708dd7..52a17f455 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,9 @@ Removes all agent configs, skills, hooks, and instructions. Does not remove the - **Louvain community detection**: Discovers functional modules by clustering call edges - **Git diff impact mapping**: `detect_changes` maps uncommitted changes to affected symbols with risk classification - **Call graph**: Resolves function calls across files and packages (import-aware, type-inferred) +- **Angular metadata**: Preserves literal `Component`, `Directive`, `Pipe`, `Injectable`, and + `NgModule` roles on existing class nodes, including selectors, standalone status, external + templates/styles, and resolvable standalone imports - **Dead code detection**: Finds functions with zero callers, excluding entry points - **Cypher-like queries**: `MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'main' RETURN g.name` @@ -445,6 +448,10 @@ codebase-memory-mcp cli --raw search_graph '{"project": "my-project", "label": " `Project`, `Package`, `Folder`, `File`, `Module`, `Class`, `Function`, `Method`, `Interface`, `Enum`, `Type`, `Route`, `Resource` +Angular declarations remain `Class` nodes. Their queryable properties include `angular_kind`, +`selector`, `standalone`, `templateUrl`, `styleUrls`, and `angular_imports`; resolved standalone +dependencies use `IMPORTS` edges with `via: "angular_metadata"`. + ### Edge Types `CONTAINS_PACKAGE`, `CONTAINS_FOLDER`, `CONTAINS_FILE`, `DEFINES`, `DEFINES_METHOD`, `IMPORTS`, `CALLS`, `HTTP_CALLS`, `ASYNC_CALLS`, `IMPLEMENTS`, `HANDLES`, `USAGE`, `CONFIGURES`, `WRITES`, `MEMBER_OF`, `TESTS`, `USES_TYPE`, `FILE_CHANGES_WITH` diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 8de94b941..5edc36700 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -979,11 +979,12 @@ static CBMFileResult *cbm_extract_file_impl(const char *source, int source_len, .root = root, }; - // Run extractors: defs + imports use separate walks (unique recursion patterns), + // Run extractors: imports + defs use separate walks (unique recursion patterns), // then a single unified cursor walk handles the remaining 7 extractors. - cbm_extract_definitions(&ctx); cbm_extract_imports(&ctx); + cbm_extract_definitions(&ctx); cbm_extract_unified(&ctx); + cbm_propagate_angular_http_wrappers(&ctx); // Channel detection (Socket.IO / EventEmitter) — JS/TS only. cbm_extract_channels(&ctx); diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index c8645f555..2b73186bd 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -195,20 +195,30 @@ typedef struct { const char **return_types; // NULL-terminated array (NULL if none) const char *route_path; // HTTP route path from decorator (e.g., "/api/users") or NULL const char *route_method; // HTTP method from decorator (e.g., "POST") or NULL - int complexity; // cyclomatic complexity - int cognitive; // cognitive complexity (nesting-weighted) - int loop_count; // number of loop constructs in the body - int loop_depth; // max nested-loop depth (bottleneck proxy) - bool is_recursive; // body contains a direct self-call (seed for "recursive") - int param_count; // number of parameters (large = complexity smell) - int max_access_depth; // deepest chained member/subscript access (a.b.c.d) - int linear_scan_in_loop; // count of linear-scan calls (find/contains/indexOf) inside loops - int alloc_in_loop; // count of allocation/append calls inside loops - bool recursion_in_loop; // a self-call occurs inside a loop body - bool unguarded_recursion; // recursive with no self-call guarded by a conditional - int lines; // body line count - uint32_t *fingerprint; // MinHash fingerprint (arena-allocated, K values) or NULL - int fingerprint_k; // number of hash values (CBM_MINHASH_K or 0) + + const char *route_framework; // Producer framework with routing semantics (e.g., "aspnet") + const char *angular_kind; // component/directive/pipe/injectable/ng_module or NULL + const char *angular_selector; // literal Component/Directive selector or NULL + const char *angular_template_url; // literal external template path or NULL + const char **angular_style_urls; // NULL-terminated literal stylesheet paths + const char **angular_imports; // NULL-terminated standalone dependency identifiers + bool angular_standalone; // literal standalone value when explicitly configured + bool angular_standalone_set; // distinguishes explicit false from an absent property + + int complexity; // cyclomatic complexity + int cognitive; // cognitive complexity (nesting-weighted) + int loop_count; // number of loop constructs in the body + int loop_depth; // max nested-loop depth (bottleneck proxy) + bool is_recursive; // body contains a direct self-call (seed for "recursive") + int param_count; // number of parameters (large = complexity smell) + int max_access_depth; // deepest chained member/subscript access (a.b.c.d) + int linear_scan_in_loop; // count of linear-scan calls (find/contains/indexOf) inside loops + int alloc_in_loop; // count of allocation/append calls inside loops + bool recursion_in_loop; // a self-call occurs inside a loop body + bool unguarded_recursion; // recursive with no self-call guarded by a conditional + int lines; // body line count + uint32_t *fingerprint; // MinHash fingerprint (arena-allocated, K values) or NULL + int fingerprint_k; // number of hash values (CBM_MINHASH_K or 0) bool is_exported; bool is_abstract; bool is_test; @@ -231,6 +241,7 @@ typedef struct { const char *callee_name; // raw callee text ("pkg.Func", "foo") const char *enclosing_func_qn; // QN of enclosing function (or module QN) const char *first_string_arg; // first string literal argument (URL, topic, key) or NULL + const char *asset_path; // normalized frontend asset path or NULL const char *second_arg_name; // second argument identifier (handler ref) or NULL CBMCallArg args[CBM_MAX_CALL_ARGS]; // first N arguments with expressions int arg_count; // number of captured arguments @@ -630,6 +641,7 @@ void cbm_extract_channels(CBMExtractCtx *ctx); // Single-pass unified extraction (replaces the 7 calls above except defs+imports). void cbm_extract_unified(CBMExtractCtx *ctx); +void cbm_propagate_angular_http_wrappers(CBMExtractCtx *ctx); // K8s / Kustomize semantic extractor (called when language is CBM_LANG_K8S or CBM_LANG_KUSTOMIZE). void cbm_extract_k8s(CBMExtractCtx *ctx); diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index afe2938be..9a8478b0c 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -42,6 +42,7 @@ static const char *lookup_string_constant(const CBMExtractCtx *ctx, const char * /* Check if a node type is a string literal */ static int is_string_like(const char *kind) { return (strcmp(kind, "string") == 0 || strcmp(kind, "string_literal") == 0 || + strcmp(kind, "template_string") == 0 || strcmp(kind, "interpreted_string_literal") == 0 || strcmp(kind, "raw_string_literal") == 0 || strcmp(kind, "string_content") == 0); } @@ -52,12 +53,109 @@ static const char *strip_quotes(CBMArena *a, const char *text) { return NULL; } int len = (int)strlen(text); - if (len >= CBM_QUOTE_PAIR && (text[0] == '"' || text[0] == '\'')) { + if (len >= CBM_QUOTE_PAIR && (text[0] == '"' || text[0] == '\'' || text[0] == '`')) { return cbm_arena_strndup(a, text + CBM_QUOTE_OFFSET, (size_t)(len - CBM_QUOTE_PAIR)); } return text; } +static bool is_typescript_language(CBMLanguage lang) { + return lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_JAVASCRIPT; +} + +static bool text_has_identifier(const char *text, const char *identifier) { + if (!text || !identifier || !identifier[0]) { + return false; + } + size_t len = strlen(identifier); + const char *at = text; + while ((at = strstr(at, identifier)) != NULL) { + bool left_ok = at == text || !(isalnum((unsigned char)at[-SKIP_ONE]) || + at[-SKIP_ONE] == '_' || at[-SKIP_ONE] == '$'); + char after = at[len]; + bool right_ok = !(isalnum((unsigned char)after) || after == '_' || after == '$'); + if (left_ok && right_ok) { + return true; + } + at += len; + } + return false; +} + +static TSNode enclosing_ts_class(TSNode node) { + for (int depth = 0; depth < LEAN_MAX_PARENT_DEPTH && !ts_node_is_null(node); depth++) { + const char *kind = ts_node_type(node); + if (strcmp(kind, "class_declaration") == 0 || strcmp(kind, "class") == 0) { + return node; + } + node = ts_node_parent(node); + } + TSNode null_node = {0}; + return null_node; +} + +static bool class_binds_angular_http_client(CBMExtractCtx *ctx, TSNode class_node, + const char *receiver) { + if (!strstr(ctx->source, "@angular/common/http") || ts_node_is_null(class_node)) { + return false; + } + TSNodeStack stack; + ts_nstack_init(&stack, ctx->arena, CBM_SZ_64); + ts_nstack_push(&stack, ctx->arena, class_node); + while (stack.count > 0) { + TSNode current = ts_nstack_pop(&stack); + const char *kind = ts_node_type(current); + bool declaration = + strcmp(kind, "required_parameter") == 0 || strcmp(kind, "optional_parameter") == 0 || + strcmp(kind, "public_field_definition") == 0 || strcmp(kind, "field_definition") == 0 || + strcmp(kind, "property_declaration") == 0; + if (declaration) { + char *text = cbm_node_text(ctx->arena, current, ctx->source); + if (text && strstr(text, "HttpClient") && text_has_identifier(text, receiver)) { + return true; + } + } + ts_nstack_push_children(&stack, ctx->arena, current); + } + return false; +} + +static const char *angular_http_client_callee(CBMExtractCtx *ctx, TSNode call_node, + const char *callee) { + if (!is_typescript_language(ctx->language) || !callee) { + return callee; + } + const char *dot = strrchr(callee, '.'); + if (!dot || !dot[SKIP_ONE]) { + return callee; + } + const char *method = dot + SKIP_ONE; + if (strcmp(method, "get") != 0 && strcmp(method, "post") != 0 && strcmp(method, "put") != 0 && + strcmp(method, "delete") != 0 && strcmp(method, "patch") != 0 && + strcmp(method, "request") != 0) { + return callee; + } + TSNode function = ts_node_child_by_field_name(call_node, TS_FIELD("function")); + if (ts_node_is_null(function) || strcmp(ts_node_type(function), "member_expression") != 0) { + return callee; + } + TSNode object = ts_node_child_by_field_name(function, TS_FIELD("object")); + if (ts_node_is_null(object)) { + return callee; + } + char *object_text = cbm_node_text(ctx->arena, object, ctx->source); + if (!object_text) { + return callee; + } + const char *receiver = strrchr(object_text, '.'); + receiver = receiver ? receiver + SKIP_ONE : object_text; + TSNode class_node = enclosing_ts_class(call_node); + if (!class_binds_angular_http_client(ctx, class_node, receiver)) { + return callee; + } + return cbm_arena_sprintf(ctx->arena, "@angular/common/http.HttpClient.%s", method); +} + // Callee suffixes for IRIS Python interop string-dispatch. Kept at file scope // (not inside the function) to satisfy cppcheck variableScope. static const char *s_py_dispatch_suffixes[] = {".classMethodValue", ".classMethodVoid", @@ -1237,7 +1335,7 @@ static const char *strip_and_validate_string_arg(CBMArena *a, char *text) { return NULL; } int len = (int)strlen(text); - if (len >= CBM_QUOTE_PAIR && (text[0] == '"' || text[0] == '\'')) { + if (len >= CBM_QUOTE_PAIR && (text[0] == '"' || text[0] == '\'' || text[0] == '`')) { text = cbm_arena_strndup(a, text + CBM_QUOTE_OFFSET, (size_t)(len - CBM_QUOTE_PAIR)); len -= CBM_QUOTE_PAIR; } @@ -1502,10 +1600,170 @@ static const char *extract_keyword_url(CBMExtractCtx *ctx, TSNode arg) { return extract_string_value(ctx, val_node); } +static TSNode enclosing_ts_function(TSNode node) { + for (int depth = 0; depth < LEAN_MAX_PARENT_DEPTH && !ts_node_is_null(node); depth++) { + const char *kind = ts_node_type(node); + if (strcmp(kind, "method_definition") == 0 || strcmp(kind, "function_declaration") == 0 || + strcmp(kind, "function_expression") == 0 || strcmp(kind, "arrow_function") == 0) { + return node; + } + node = ts_node_parent(node); + } + TSNode null_node = {0}; + return null_node; +} + +static TSNode find_local_string_initializer(CBMExtractCtx *ctx, TSNode use_node, const char *name) { + TSNode function = enclosing_ts_function(use_node); + TSNode best = {0}; + if (ts_node_is_null(function)) { + return best; + } + uint32_t use_start = ts_node_start_byte(use_node); + uint32_t best_start = 0; + TSNodeStack stack; + ts_nstack_init(&stack, ctx->arena, CBM_SZ_64); + ts_nstack_push(&stack, ctx->arena, function); + while (stack.count > 0) { + TSNode current = ts_nstack_pop(&stack); + uint32_t current_start = ts_node_start_byte(current); + if (current_start >= use_start) { + continue; + } + if (strcmp(ts_node_type(current), "variable_declarator") == 0) { + TSNode name_node = ts_node_child_by_field_name(current, TS_FIELD("name")); + TSNode value_node = ts_node_child_by_field_name(current, TS_FIELD("value")); + if (!ts_node_is_null(name_node) && !ts_node_is_null(value_node) && + is_string_like(ts_node_type(value_node))) { + char *candidate = cbm_node_text(ctx->arena, name_node, ctx->source); + if (candidate && strcmp(candidate, name) == 0 && current_start >= best_start) { + best = value_node; + best_start = current_start; + } + } + } + ts_nstack_push_children(&stack, ctx->arena, current); + } + return best; +} + +static const char *normalize_typescript_asset_path(CBMExtractCtx *ctx, const char *raw) { + const char *path = strip_quotes(ctx->arena, raw); + if (!path) { + return NULL; + } + while (strncmp(path, "../", strlen("../")) == 0) { + path += strlen("../"); + } + if (strncmp(path, "./", strlen("./")) == 0) { + path += strlen("./"); + } + + bool needs_leading_slash = false; + if (strncmp(path, "/assets", strlen("/assets")) == 0) { + char boundary = path[strlen("/assets")]; + if (boundary != '\0' && boundary != '/' && boundary != '?' && boundary != '#') { + return NULL; + } + } else if (strncmp(path, "assets", strlen("assets")) == 0) { + char boundary = path[strlen("assets")]; + if (boundary != '\0' && boundary != '/' && boundary != '?' && boundary != '#') { + return NULL; + } + needs_leading_slash = true; + } else { + return NULL; + } + + char normalized[CBM_SZ_512]; + size_t out = 0; + if (needs_leading_slash) { + normalized[out++] = '/'; + } + for (size_t i = 0; path[i] && path[i] != '?' && path[i] != '#';) { + if (path[i] == '$' && path[i + SKIP_ONE] == '{') { + const char *close = strchr(path + i + PAIR_LEN, '}'); + if (!close || out + PAIR_LEN >= sizeof(normalized)) { + return NULL; + } + normalized[out++] = '{'; + normalized[out++] = '}'; + i = (size_t)(close - path) + SKIP_ONE; + continue; + } + if (path[i] == '\\' || out + SKIP_ONE >= sizeof(normalized)) { + return NULL; + } + normalized[out++] = path[i++]; + } + normalized[out] = '\0'; + + size_t len = strlen(normalized); + if (strstr(normalized, "/../") || strstr(normalized, "/./") || + (len >= strlen("/..") && strcmp(normalized + len - strlen("/.."), "/..") == 0) || + (len >= strlen("/.") && strcmp(normalized + len - strlen("/."), "/.") == 0)) { + return NULL; + } + return cbm_arena_strdup(ctx->arena, normalized); +} + +static const char *normalize_typescript_route(CBMExtractCtx *ctx, const char *raw) { + const char *text = strip_quotes(ctx->arena, raw); + if (!text || normalize_typescript_asset_path(ctx, raw)) { + return NULL; + } + const char *start = text; + const char *api = strstr(text, "/api/"); + const char *internal = strstr(text, "/internal/"); + if (api && internal) { + start = api < internal ? api : internal; + } else if (api) { + start = api; + } else if (internal) { + start = internal; + } else if (text[0] != '/') { + const char *scheme = strstr(text, "://"); + start = scheme ? strchr(scheme + strlen("://"), '/') : NULL; + if (!start) { + return NULL; + } + } + + char normalized[CBM_SZ_512]; + size_t out = 0; + for (size_t i = 0; start[i] && start[i] != '?' && start[i] != '#';) { + if (start[i] == '$' && start[i + SKIP_ONE] == '{') { + const char *close = strchr(start + i + PAIR_LEN, '}'); + if (!close || out + PAIR_LEN >= sizeof(normalized)) { + return NULL; + } + normalized[out++] = '{'; + normalized[out++] = '}'; + i = (size_t)(close - start) + SKIP_ONE; + continue; + } + if (out + SKIP_ONE >= sizeof(normalized)) { + return NULL; + } + normalized[out++] = start[i++]; + } + normalized[out] = '\0'; + return out > 0 && normalized[0] == '/' ? cbm_arena_strdup(ctx->arena, normalized) : NULL; +} + // Try to extract URL/topic from a positional argument (string or constant). static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const char *ak) { if (is_string_like(ak)) { char *text = cbm_node_text(ctx->arena, arg, ctx->source); + if (is_typescript_language(ctx->language)) { + if (normalize_typescript_asset_path(ctx, text)) { + return NULL; + } + const char *route = normalize_typescript_route(ctx, text); + if (route) { + return route; + } + } const char *validated = strip_and_validate_string_arg(ctx->arena, text); if (validated) { return validated; @@ -1514,12 +1772,56 @@ static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const if (strcmp(ak, "identifier") == 0) { char *const_name = cbm_node_text(ctx->arena, arg, ctx->source); if (const_name) { - return lookup_string_constant(ctx, const_name); + const char *value = lookup_string_constant(ctx, const_name); + if (value) { + return is_typescript_language(ctx->language) + ? normalize_typescript_route(ctx, value) + : value; + } + if (is_typescript_language(ctx->language)) { + TSNode initializer = find_local_string_initializer(ctx, arg, const_name); + if (!ts_node_is_null(initializer)) { + char *raw = cbm_node_text(ctx->arena, initializer, ctx->source); + return normalize_typescript_route(ctx, raw); + } + } } } return NULL; } +static const char *extract_typescript_asset_arg(CBMExtractCtx *ctx, TSNode args) { + if (!is_typescript_language(ctx->language) || ts_node_named_child_count(args) == 0) { + return NULL; + } + TSNode arg = ts_node_named_child(args, 0); + if (strcmp(ts_node_type(arg), "argument") == 0 && ts_node_named_child_count(arg) > 0) { + arg = ts_node_named_child(arg, 0); + } + const char *kind = ts_node_type(arg); + if (is_string_like(kind)) { + char *raw = cbm_node_text(ctx->arena, arg, ctx->source); + return normalize_typescript_asset_path(ctx, raw); + } + if (strcmp(kind, "identifier") != 0) { + return NULL; + } + char *name = cbm_node_text(ctx->arena, arg, ctx->source); + if (!name) { + return NULL; + } + const char *value = lookup_string_constant(ctx, name); + if (value) { + return normalize_typescript_asset_path(ctx, value); + } + TSNode initializer = find_local_string_initializer(ctx, arg, name); + if (ts_node_is_null(initializer)) { + return NULL; + } + char *raw = cbm_node_text(ctx->arena, initializer, ctx->source); + return normalize_typescript_asset_path(ctx, raw); +} + // Extract URL/topic from keyword or positional args. static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) { uint32_t nc = ts_node_named_child_count(args); @@ -1873,6 +2175,7 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk if (cbm_kind_in_set(node, spec->call_node_types)) { char *callee = extract_callee_name(ctx->arena, node, ctx->source, ctx->language); + callee = (char *)angular_http_client_callee(ctx, node, callee); // Keyword-filter callees, but keep builtins we mint a node for (len, str, // ...) so the LSP-resolved builtin call still forms a CALLS edge. if (callee && callee[0] && @@ -1919,6 +2222,7 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); if (!ts_node_is_null(args)) { call.first_string_arg = extract_url_or_topic_arg(ctx, args); + call.asset_path = extract_typescript_asset_arg(ctx, args); if (call.first_string_arg && call.first_string_arg[0] == '/') { call.second_arg_name = extract_handler_arg(ctx, args); } @@ -1967,3 +2271,403 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk extract_cpp_implicit_calls(ctx, node, ts_node_type(node), state->enclosing_func_qn); } } + +static const char *angular_http_sink_method(const CBMCall *call) { + static const char prefix[] = "@angular/common/http.HttpClient."; + if (!call || !call->callee_name || + strncmp(call->callee_name, prefix, sizeof(prefix) - SKIP_ONE) != 0) { + return NULL; + } + return call->callee_name + sizeof(prefix) - SKIP_ONE; +} + +static bool is_angular_http_client_call(const CBMCall *call) { + const char *method = angular_http_sink_method(call); + return method && (strcmp(method, "get") == 0 || strcmp(method, "post") == 0 || + strcmp(method, "put") == 0 || strcmp(method, "delete") == 0 || + strcmp(method, "patch") == 0 || strcmp(method, "request") == 0); +} + +static bool is_angular_http_sink(const CBMCall *call) { + const char *method = angular_http_sink_method(call); + return method && strcmp(method, "request") != 0 && is_angular_http_client_call(call); +} + +static bool is_typescript_identifier(const char *text) { + if (!text || !(isalpha((unsigned char)text[0]) || text[0] == '_' || text[0] == '$')) { + return false; + } + for (const char *p = text + SKIP_ONE; *p; p++) { + if (!(isalnum((unsigned char)*p) || *p == '_' || *p == '$')) { + return false; + } + } + return true; +} + +static const CBMCallArg *find_positional_call_arg(const CBMCall *call, int index) { + if (!call) { + return NULL; + } + for (int i = 0; i < call->arg_count; i++) { + if (!call->args[i].keyword && call->args[i].index == index) { + return &call->args[i]; + } + } + return NULL; +} + +static bool node_is_within_definition(TSNode node, const CBMDefinition *def) { + uint32_t start = ts_node_start_point(node).row + TS_LINE_OFFSET; + uint32_t end = ts_node_end_point(node).row + TS_LINE_OFFSET; + return start >= def->start_line && end <= def->end_line; +} + +static bool node_overlaps_definition(TSNode node, const CBMDefinition *def) { + uint32_t start = ts_node_start_point(node).row + TS_LINE_OFFSET; + uint32_t end = ts_node_end_point(node).row + TS_LINE_OFFSET; + return start <= def->end_line && end >= def->start_line; +} + +static bool node_text_equals(CBMExtractCtx *ctx, TSNode node, const char *expected) { + if (ts_node_is_null(node)) { + return false; + } + char *text = cbm_node_text(ctx->arena, node, ctx->source); + return text && strcmp(text, expected) == 0; +} + +static bool typescript_identifier_is_reassigned(CBMExtractCtx *ctx, const CBMDefinition *def, + const char *name) { + TSNodeStack stack; + ts_nstack_init(&stack, ctx->arena, CBM_SZ_64); + ts_nstack_push(&stack, ctx->arena, ctx->root); + while (stack.count > 0) { + TSNode current = ts_nstack_pop(&stack); + if (!node_overlaps_definition(current, def)) { + continue; + } + if (!node_is_within_definition(current, def)) { + ts_nstack_push_children(&stack, ctx->arena, current); + continue; + } + const char *kind = ts_node_type(current); + TSNode target = {0}; + if (strcmp(kind, "assignment_expression") == 0 || + strcmp(kind, "augmented_assignment_expression") == 0) { + target = ts_node_child_by_field_name(current, TS_FIELD("left")); + if (ts_node_is_null(target) && ts_node_named_child_count(current) > 0) { + target = ts_node_named_child(current, 0); + } + } else if (strcmp(kind, "update_expression") == 0) { + target = ts_node_child_by_field_name(current, TS_FIELD("argument")); + if (ts_node_is_null(target) && ts_node_named_child_count(current) > 0) { + target = ts_node_named_child(current, 0); + } + } + if (!ts_node_is_null(target) && node_text_equals(ctx, target, name)) { + return true; + } + ts_nstack_push_children(&stack, ctx->arena, current); + } + return false; +} + +static bool variable_declarator_is_const(CBMExtractCtx *ctx, TSNode declarator) { + TSNode declaration = ts_node_parent(declarator); + if (ts_node_is_null(declaration) || + strcmp(ts_node_type(declaration), "lexical_declaration") != 0) { + return false; + } + char *text = cbm_node_text(ctx->arena, declaration, ctx->source); + if (!text) { + return false; + } + while (isspace((unsigned char)*text)) { + text++; + } + return strncmp(text, "const", strlen("const")) == 0 && + !(isalnum((unsigned char)text[strlen("const")]) || text[strlen("const")] == '_' || + text[strlen("const")] == '$'); +} + +static TSNode find_const_string_initializer(CBMExtractCtx *ctx, const CBMDefinition *def, + const char *name, int before_line) { + TSNode found = {0}; + int matches = 0; + TSNodeStack stack; + ts_nstack_init(&stack, ctx->arena, CBM_SZ_64); + ts_nstack_push(&stack, ctx->arena, ctx->root); + while (stack.count > 0) { + TSNode current = ts_nstack_pop(&stack); + uint32_t line = ts_node_start_point(current).row + TS_LINE_OFFSET; + if (!node_overlaps_definition(current, def)) { + continue; + } + if (!node_is_within_definition(current, def)) { + ts_nstack_push_children(&stack, ctx->arena, current); + continue; + } + if (line >= (uint32_t)before_line) { + continue; + } + if (strcmp(ts_node_type(current), "variable_declarator") == 0) { + TSNode name_node = ts_node_child_by_field_name(current, TS_FIELD("name")); + if (node_text_equals(ctx, name_node, name)) { + matches++; + TSNode value_node = ts_node_child_by_field_name(current, TS_FIELD("value")); + if (matches == 1 && variable_declarator_is_const(ctx, current) && + !ts_node_is_null(value_node) && is_string_like(ts_node_type(value_node))) { + found = value_node; + } else { + TSNode none = {0}; + found = none; + } + } + } + ts_nstack_push_children(&stack, ctx->arena, current); + } + if (matches != 1 || typescript_identifier_is_reassigned(ctx, def, name)) { + TSNode none = {0}; + return none; + } + return found; +} + +static const CBMDefinition *find_definition_by_qn(const CBMExtractCtx *ctx, const char *qn) { + if (!qn) { + return NULL; + } + for (int i = 0; i < ctx->result->defs.count; i++) { + const CBMDefinition *def = &ctx->result->defs.items[i]; + if (def->qualified_name && strcmp(def->qualified_name, qn) == 0) { + return def; + } + } + return NULL; +} + +static const char *resolve_angular_wrapper_resource(CBMExtractCtx *ctx, const CBMCall *caller, + int parameter_index, bool asset) { + const CBMCallArg *arg = find_positional_call_arg(caller, parameter_index); + if (!arg || !arg->expr) { + return NULL; + } + if (arg->value) { + return asset ? normalize_typescript_asset_path(ctx, arg->value) + : normalize_typescript_route(ctx, arg->value); + } + if (!is_typescript_identifier(arg->expr)) { + return NULL; + } + const CBMDefinition *caller_def = find_definition_by_qn(ctx, caller->enclosing_func_qn); + if (!caller_def) { + return NULL; + } + TSNode initializer = + find_const_string_initializer(ctx, caller_def, arg->expr, caller->start_line); + if (ts_node_is_null(initializer)) { + return NULL; + } + char *raw = cbm_node_text(ctx->arena, initializer, ctx->source); + return asset ? normalize_typescript_asset_path(ctx, raw) : normalize_typescript_route(ctx, raw); +} + +static bool call_targets_wrapper(const CBMCall *call, const CBMDefinition *wrapper) { + if (!call || !call->callee_name || !call->enclosing_func_qn || !wrapper->name || + !wrapper->parent_class || call->is_method) { + return false; + } + size_t class_len = strlen(wrapper->parent_class); + if (strncmp(call->enclosing_func_qn, wrapper->parent_class, class_len) != 0 || + call->enclosing_func_qn[class_len] != '.') { + return false; + } + if (strcmp(call->callee_name, wrapper->name) == 0) { + return true; + } + static const char self_prefix[] = "this."; + return strncmp(call->callee_name, self_prefix, sizeof(self_prefix) - SKIP_ONE) == 0 && + strcmp(call->callee_name + sizeof(self_prefix) - SKIP_ONE, wrapper->name) == 0; +} + +static bool has_synthesized_http_call(const CBMExtractCtx *ctx, const char *caller_qn, + const char *sink_callee, const char *route, + const char *asset_path) { + for (int i = 0; i < ctx->result->calls.count; i++) { + const CBMCall *existing = &ctx->result->calls.items[i]; + bool same_route = + route && existing->first_string_arg && strcmp(existing->first_string_arg, route) == 0; + bool same_asset = + asset_path && existing->asset_path && strcmp(existing->asset_path, asset_path) == 0; + if (existing->callee_name && existing->enclosing_func_qn && + strcmp(existing->callee_name, sink_callee) == 0 && + strcmp(existing->enclosing_func_qn, caller_qn) == 0 && (same_route || same_asset)) { + return true; + } + } + return false; +} + +static const char *skip_typescript_parameter_prefix(const char *start, const char *end) { + static const char *modifiers[] = {"public", "private", "protected", "readonly", NULL}; + while (start < end) { + while (start < end && isspace((unsigned char)*start)) { + start++; + } + bool skipped = false; + for (int i = 0; modifiers[i]; i++) { + size_t len = strlen(modifiers[i]); + if ((size_t)(end - start) > len && strncmp(start, modifiers[i], len) == 0 && + isspace((unsigned char)start[len])) { + start += len; + skipped = true; + break; + } + } + if (!skipped) { + break; + } + } + if (end - start >= (int)strlen("...") && strncmp(start, "...", strlen("...")) == 0) { + start += strlen("..."); + } + return start; +} + +static bool typescript_parameter_segment_matches(const char *start, const char *end, + const char *name) { + start = skip_typescript_parameter_prefix(start, end); + if (start >= end || !(isalpha((unsigned char)*start) || *start == '_' || *start == '$')) { + return false; + } + const char *identifier_end = start + SKIP_ONE; + while (identifier_end < end && (isalnum((unsigned char)*identifier_end) || + *identifier_end == '_' || *identifier_end == '$')) { + identifier_end++; + } + return (size_t)(identifier_end - start) == strlen(name) && + strncmp(start, name, (size_t)(identifier_end - start)) == 0; +} + +static int typescript_signature_parameter_index(const char *signature, const char *name) { + if (!signature || !name) { + return -1; + } + const char *start = strchr(signature, '('); + if (!start) { + return -1; + } + start++; + const char *segment = start; + int index = 0; + int nested = 0; + char quote = '\0'; + bool escaped = false; + for (const char *p = start; *p; p++) { + if (quote) { + if (escaped) { + escaped = false; + } else if (*p == '\\') { + escaped = true; + } else if (*p == quote) { + quote = '\0'; + } + continue; + } + if (*p == '\'' || *p == '"' || *p == '`') { + quote = *p; + continue; + } + if (*p == '(' || *p == '[' || *p == '{' || *p == '<') { + nested++; + continue; + } + if (*p == ']' || *p == '}' || *p == '>') { + if (nested > 0) { + nested--; + } + continue; + } + if (*p == ')' && nested > 0) { + nested--; + continue; + } + if ((*p == ',' || *p == ')') && nested == 0) { + if (typescript_parameter_segment_matches(segment, p, name)) { + return index; + } + if (*p == ')') { + break; + } + segment = p + SKIP_ONE; + index++; + } + } + return -1; +} + +void cbm_propagate_angular_http_wrappers(CBMExtractCtx *ctx) { + if (!ctx || !is_typescript_language(ctx->language) || + !strstr(ctx->source, "@angular/common/http")) { + return; + } + int original_call_count = ctx->result->calls.count; + for (int di = 0; di < ctx->result->defs.count; di++) { + const CBMDefinition *wrapper = &ctx->result->defs.items[di]; + if (!wrapper->label || strcmp(wrapper->label, "Method") != 0 || !wrapper->signature || + !wrapper->parent_class) { + continue; + } + const CBMCall *sink = NULL; + int sink_count = 0; + for (int ci = 0; ci < original_call_count; ci++) { + const CBMCall *call = &ctx->result->calls.items[ci]; + if (call->enclosing_func_qn && wrapper->qualified_name && + strcmp(call->enclosing_func_qn, wrapper->qualified_name) == 0 && + is_angular_http_client_call(call)) { + sink = call; + sink_count++; + } + } + if (sink_count != 1 || !is_angular_http_sink(sink)) { + continue; + } + const CBMCallArg *sink_url = find_positional_call_arg(sink, 0); + if (!sink_url || !is_typescript_identifier(sink_url->expr)) { + continue; + } + int url_parameter_index = + typescript_signature_parameter_index(wrapper->signature, sink_url->expr); + if (url_parameter_index < 0 || + typescript_identifier_is_reassigned(ctx, wrapper, sink_url->expr)) { + continue; + } + const char *sink_callee = sink->callee_name; + for (int ci = 0; ci < original_call_count; ci++) { + const CBMCall *caller = &ctx->result->calls.items[ci]; + if (!call_targets_wrapper(caller, wrapper)) { + continue; + } + const char *route = + resolve_angular_wrapper_resource(ctx, caller, url_parameter_index, false); + const char *asset_path = + route ? NULL + : resolve_angular_wrapper_resource(ctx, caller, url_parameter_index, true); + if ((!route && !asset_path) || + has_synthesized_http_call(ctx, caller->enclosing_func_qn, sink_callee, route, + asset_path)) { + continue; + } + CBMCall synthesized = {0}; + synthesized.callee_name = sink_callee; + synthesized.enclosing_func_qn = caller->enclosing_func_qn; + synthesized.first_string_arg = route; + synthesized.asset_path = asset_path; + synthesized.start_line = caller->start_line; + synthesized.loop_depth = caller->loop_depth; + synthesized.branch_depth = caller->branch_depth; + cbm_calls_push(&ctx->result->calls, ctx->arena, synthesized); + } + } +} diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 2de63ffbb..f89c5c4b2 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -1272,26 +1272,59 @@ static const char *decorator_method_name(const char *attr_text) { /* HTTP method for a Spring/JAX-RS style annotation name (e.g. "GetMapping" → * "GET", "RequestMapping" → "ANY", JAX-RS "GET" → "GET"). Returns NULL when the * annotation is not a route-mapping annotation. */ +static const char *annotation_short_name(const char *name, char *buf, size_t buf_sz) { + if (!name || !buf || buf_sz == 0) { + return NULL; + } + const char *short_name = strrchr(name, '.'); + short_name = short_name ? short_name + SKIP_CHAR : name; + size_t len = strlen(short_name); + static const char suffix[] = "Attribute"; + size_t suffix_len = sizeof(suffix) - SKIP_CHAR; + if (len > suffix_len && strcmp(short_name + len - suffix_len, suffix) == 0) { + len -= suffix_len; + } + if (len == 0 || len >= buf_sz) { + return NULL; + } + memcpy(buf, short_name, len); + buf[len] = '\0'; + return buf; +} + +static bool is_aspnet_route_annotation(const char *name) { + char short_name[CBM_SZ_64]; + const char *n = annotation_short_name(name, short_name, sizeof(short_name)); + return n && (strcmp(n, "Route") == 0 || strcmp(n, "HttpGet") == 0 || + strcmp(n, "HttpPost") == 0 || strcmp(n, "HttpPut") == 0 || + strcmp(n, "HttpDelete") == 0 || strcmp(n, "HttpPatch") == 0); +} + static const char *annotation_route_method(const char *name) { if (!name) { return NULL; } - if (strcmp(name, "GetMapping") == 0) { + char short_name[CBM_SZ_64]; + name = annotation_short_name(name, short_name, sizeof(short_name)); + if (!name) { + return NULL; + } + if (strcmp(name, "GetMapping") == 0 || strcmp(name, "HttpGet") == 0) { return "GET"; } - if (strcmp(name, "PostMapping") == 0) { + if (strcmp(name, "PostMapping") == 0 || strcmp(name, "HttpPost") == 0) { return "POST"; } - if (strcmp(name, "PutMapping") == 0) { + if (strcmp(name, "PutMapping") == 0 || strcmp(name, "HttpPut") == 0) { return "PUT"; } - if (strcmp(name, "DeleteMapping") == 0) { + if (strcmp(name, "DeleteMapping") == 0 || strcmp(name, "HttpDelete") == 0) { return "DELETE"; } - if (strcmp(name, "PatchMapping") == 0) { + if (strcmp(name, "PatchMapping") == 0 || strcmp(name, "HttpPatch") == 0) { return "PATCH"; } - if (strcmp(name, "RequestMapping") == 0) { + if (strcmp(name, "RequestMapping") == 0 || strcmp(name, "Route") == 0) { return "ANY"; } /* JAX-RS bare-verb annotations (@GET/@POST/...) — path comes from @Path. */ @@ -1315,7 +1348,8 @@ static TSNode find_decorator_args(TSNode call_node) { if (ts_node_is_null(args)) { for (uint32_t ai = 0; ai < ts_node_named_child_count(call_node); ai++) { TSNode ac = ts_node_named_child(call_node, ai); - if (strcmp(ts_node_type(ac), "argument_list") == 0) { + if (strcmp(ts_node_type(ac), "argument_list") == 0 || + strcmp(ts_node_type(ac), "attribute_argument_list") == 0) { return ac; } } @@ -1328,7 +1362,8 @@ static bool is_route_string_kind(const char *kind) { strcmp(kind, "interpreted_string_literal") == 0; } -static const char *route_path_from_string_node(CBMArena *a, TSNode node, const char *source) { +static const char *route_path_from_string_node(CBMArena *a, TSNode node, const char *source, + bool allow_relative) { if (!is_route_string_kind(ts_node_type(node))) { return NULL; } @@ -1340,21 +1375,22 @@ static const char *route_path_from_string_node(CBMArena *a, TSNode node, const c if (plen >= PAIR_CHARS && (path[0] == '"' || path[0] == '\'')) { path = cbm_arena_strndup(a, path + SKIP_CHAR, (size_t)(plen - PAIR_CHARS)); } - return (path && path[0] == '/') ? path : NULL; + return (path && (allow_relative || path[0] == '/')) ? path : NULL; } static const char *find_route_path_literal(CBMArena *a, TSNode node, const char *source, - int max_depth) { + int max_depth, bool allow_relative) { if (ts_node_is_null(node) || max_depth < 0) { return NULL; } - const char *path = route_path_from_string_node(a, node, source); + const char *path = route_path_from_string_node(a, node, source, allow_relative); if (path || max_depth == 0) { return path; } uint32_t nc = ts_node_named_child_count(node); for (uint32_t i = 0; i < nc && i < DECORATOR_SCAN_LIMIT; i++) { - path = find_route_path_literal(a, ts_node_named_child(node, i), source, max_depth - 1); + path = find_route_path_literal(a, ts_node_named_child(node, i), source, max_depth - 1, + allow_relative); if (path) { return path; } @@ -1363,7 +1399,8 @@ static const char *find_route_path_literal(CBMArena *a, TSNode node, const char } // Extract route path from decorator arguments (first string that starts with /). -static const char *extract_route_path_from_args(CBMArena *a, TSNode args, const char *source) { +static const char *extract_route_path_from_args(CBMArena *a, TSNode args, const char *source, + bool allow_relative) { uint32_t nc = ts_node_named_child_count(args); for (uint32_t ai = 0; ai < nc && ai < DECORATOR_SCAN_LIMIT; ai++) { TSNode arg = ts_node_named_child(args, ai); @@ -1372,7 +1409,8 @@ static const char *extract_route_path_from_args(CBMArena *a, TSNode args, const * @GetMapping(path = {"/orders"}) * Walk a bounded subtree and keep the first string literal that is * path-shaped, while ignoring non-route literals such as media types. */ - const char *path = find_route_path_literal(a, arg, source, CBM_DESCENDANT_MAX_DEPTH); + const char *path = + find_route_path_literal(a, arg, source, CBM_DESCENDANT_MAX_DEPTH, allow_relative); if (path) { return path; } @@ -1518,7 +1556,7 @@ static bool try_route_from_decorator_call(CBMArena *a, TSNode dchild, const char TSNode args = find_decorator_args(dchild); if (!ts_node_is_null(args)) { - const char *path = extract_route_path_from_args(a, args, source); + const char *path = extract_route_path_from_args(a, args, source, false); if (path) { *out_path = path; *out_method = method; @@ -1598,7 +1636,7 @@ static bool try_route_from_annotation(CBMArena *a, TSNode annotation, const char TSNode args = annotation_args_node(annotation); const char *path = NULL; if (!ts_node_is_null(args)) { - path = extract_route_path_from_args(a, args, source); + path = extract_route_path_from_args(a, args, source, is_aspnet_route_annotation(name)); } *out_path = path ? path : "/"; *out_method = method; @@ -1612,19 +1650,9 @@ static bool try_route_from_annotation(CBMArena *a, TSNode annotation, const char static bool extract_route_from_annotations(CBMArena *a, TSNode func_node, const char *source, const CBMLangSpec *spec, const char **out_path, const char **out_method) { - TSNode modifiers = find_jvm_modifiers(func_node, spec->language); - if (!ts_node_is_null(modifiers)) { - uint32_t mc = ts_node_child_count(modifiers); - for (uint32_t mi = 0; mi < mc; mi++) { - TSNode mchild = ts_node_child(modifiers, mi); - if (cbm_kind_in_set(mchild, spec->decorator_node_types) && - try_route_from_annotation(a, mchild, source, out_path, out_method)) { - return true; - } - } - } - /* Direct-child annotations (some grammars attach the annotation as a child - * of the method node rather than under `modifiers`). */ + /* Scan every direct annotation and every direct wrapper. C# permits several + * sibling attribute_list nodes (for example [Authorize] then [HttpGet]); + * looking up only the first wrapper silently misses the route attribute. */ uint32_t cc = ts_node_child_count(func_node); for (uint32_t ci = 0; ci < cc; ci++) { TSNode child = ts_node_child(func_node, ci); @@ -1632,6 +1660,14 @@ static bool extract_route_from_annotations(CBMArena *a, TSNode func_node, const try_route_from_annotation(a, child, source, out_path, out_method)) { return true; } + uint32_t wc = ts_node_child_count(child); + for (uint32_t wi = 0; wi < wc; wi++) { + TSNode wrapped = ts_node_child(child, wi); + if (cbm_kind_in_set(wrapped, spec->decorator_node_types) && + try_route_from_annotation(a, wrapped, source, out_path, out_method)) { + return true; + } + } } return false; } @@ -1700,8 +1736,8 @@ static const char *join_route_paths(CBMArena *a, const char *prefix, const char return cbm_arena_sprintf(a, "%s%s", prefix, path); } -static const char *spring_class_route_prefix(CBMArena *a, TSNode class_node, const char *source, - const CBMLangSpec *spec) { +static const char *class_route_prefix(CBMArena *a, TSNode class_node, const char *source, + const CBMLangSpec *spec) { const char *prefix = NULL; const char *method = NULL; if (extract_route_from_annotations(a, class_node, source, spec, &prefix, &method)) { @@ -1710,6 +1746,109 @@ static const char *spring_class_route_prefix(CBMArena *a, TSNode class_node, con return NULL; } +static const char *annotation_string_value(CBMArena *a, TSNode node, const char *source, + const CBMLangSpec *spec, const char *wanted_name) { + uint32_t count = ts_node_child_count(node); + for (uint32_t i = 0; i < count; i++) { + TSNode child = ts_node_child(node, i); + uint32_t candidate_count = cbm_kind_in_set(child, spec->decorator_node_types) + ? SKIP_ONE + : ts_node_child_count(child); + for (uint32_t ci = 0; ci < candidate_count; ci++) { + TSNode candidate = cbm_kind_in_set(child, spec->decorator_node_types) + ? child + : ts_node_child(child, ci); + if (ts_node_is_null(candidate) || + !cbm_kind_in_set(candidate, spec->decorator_node_types)) { + continue; + } + TSNode name_node = annotation_name_node(candidate); + if (ts_node_is_null(name_node)) { + continue; + } + char *raw_name = cbm_node_text(a, name_node, source); + char short_name[CBM_SZ_64]; + const char *name = annotation_short_name(raw_name, short_name, sizeof(short_name)); + if (!name || strcmp(name, wanted_name) != 0) { + continue; + } + TSNode args = annotation_args_node(candidate); + if (ts_node_is_null(args)) { + return NULL; + } + return extract_route_path_from_args(a, args, source, true); + } + } + return NULL; +} + +static const char *replace_route_token(CBMArena *a, const char *path, const char *token, + const char *value) { + if (!path || !token || !value) { + return path; + } + const char *at = strstr(path, token); + if (!at) { + return path; + } + size_t before = (size_t)(at - path); + return cbm_arena_sprintf(a, "%.*s%s%s", (int)before, path, value, at + strlen(token)); +} + +static const char *aspnet_controller_segment(CBMArena *a, TSNode class_node, const char *source) { + TSNode name_node = ts_node_child_by_field_name(class_node, TS_FIELD("name")); + if (ts_node_is_null(name_node)) { + return NULL; + } + char *name = cbm_node_text(a, name_node, source); + if (!name) { + return NULL; + } + size_t len = strlen(name); + static const char suffix[] = "Controller"; + size_t suffix_len = sizeof(suffix) - SKIP_CHAR; + if (len > suffix_len && strcmp(name + len - suffix_len, suffix) == 0) { + len -= suffix_len; + } + return cbm_arena_strndup(a, name, len); +} + +static const char *aspnet_major_version(CBMArena *a, TSNode class_node, const char *source, + const CBMLangSpec *spec) { + const char *version = annotation_string_value(a, class_node, source, spec, "ApiVersion"); + if (!version) { + return NULL; + } + const char *dot = strchr(version, '.'); + return dot ? cbm_arena_strndup(a, version, (size_t)(dot - version)) : version; +} + +static const char *compose_aspnet_route(CBMArena *a, TSNode class_node, const char *source, + const CBMLangSpec *spec, const char *method_name, + const char *method_path) { + const char *prefix = class_route_prefix(a, class_node, source, spec); + const char *path = join_route_paths(a, prefix, method_path); + if (!path) { + return NULL; + } + if (path[0] != '/') { + path = cbm_arena_sprintf(a, "/%s", path); + } + const char *controller = aspnet_controller_segment(a, class_node, source); + if (controller) { + path = replace_route_token(a, path, "[controller]", controller); + } + if (method_name) { + path = replace_route_token(a, path, "[action]", method_name); + } + const char *version = aspnet_major_version(a, class_node, source, spec); + if (version) { + path = replace_route_token(a, path, "{version:apiVersion}", version); + path = replace_route_token(a, path, "{version}", version); + } + return path; +} + // Extract decorator names from preceding decorator/annotation nodes // Count annotations inside a Java/Kotlin/C# "modifiers" node. static int count_modifier_annotations(TSNode modifiers, const CBMLangSpec *spec) { @@ -1863,6 +2002,311 @@ static const char **extract_decorators(CBMArena *a, TSNode node, const char *sou return result; } +typedef struct { + const char *kind; + const char *selector; + const char *template_url; + const char **style_urls; + const char **imports; + bool standalone; + bool standalone_set; +} angular_metadata_t; + +static bool angular_core_import_matches(const CBMExtractCtx *ctx, const char *decorator_name) { + const char *dot = strrchr(decorator_name, '.'); + size_t local_len = dot ? (size_t)(dot - decorator_name) : strlen(decorator_name); + for (int i = 0; i < ctx->result->imports.count; i++) { + const CBMImport *imp = &ctx->result->imports.items[i]; + if (!imp->local_name || !imp->module_path || + strcmp(imp->module_path, "@angular/core") != 0) { + continue; + } + if (strlen(imp->local_name) == local_len && + strncmp(imp->local_name, decorator_name, local_len) == 0) { + return true; + } + } + return false; +} + +static const char *angular_kind_from_decorator(CBMExtractCtx *ctx, TSNode fn) { + if (ts_node_is_null(fn)) { + return NULL; + } + char *text = cbm_node_text(ctx->arena, fn, ctx->source); + if (!text || !text[0]) { + return NULL; + } + if (!angular_core_import_matches(ctx, text)) { + return NULL; + } + const char *short_name = strrchr(text, '.'); + short_name = short_name ? short_name + SKIP_ONE : text; + if (strcmp(short_name, "Component") == 0) { + return "component"; + } + if (strcmp(short_name, "Directive") == 0) { + return "directive"; + } + if (strcmp(short_name, "Pipe") == 0) { + return "pipe"; + } + if (strcmp(short_name, "Injectable") == 0) { + return "injectable"; + } + if (strcmp(short_name, "NgModule") == 0) { + return "ng_module"; + } + return NULL; +} + +static const char *angular_string_literal(CBMArena *a, TSNode node, const char *source) { + const char *kind = ts_node_type(node); + if (strcmp(kind, "string") != 0 && strcmp(kind, "string_literal") != 0) { + return NULL; + } + char *text = cbm_node_text(a, node, source); + size_t len = text ? strlen(text) : 0; + if (len < CBM_QUOTE_PAIR || (text[0] != '\'' && text[0] != '"') || + text[len - SKIP_ONE] != text[0]) { + return NULL; + } + return cbm_arena_strndup(a, text + SKIP_ONE, len - CBM_QUOTE_PAIR); +} + +static bool angular_array_contains(const char *const *items, int count, const char *value) { + for (int i = 0; i < count; i++) { + if (strcmp(items[i], value) == 0) { + return true; + } + } + return false; +} + +static bool angular_string_array(CBMArena *a, TSNode node, const char *source, const char ***out) { + *out = NULL; + if (strcmp(ts_node_type(node), "array") != 0) { + return false; + } + uint32_t count = ts_node_named_child_count(node); + const char **items = + (const char **)cbm_arena_alloc(a, sizeof(const char *) * (count + NULL_TERM)); + if (!items) { + return false; + } + int used = 0; + for (uint32_t i = 0; i < count; i++) { + const char *value = angular_string_literal(a, ts_node_named_child(node, i), source); + if (!value) { + return false; + } + if (!angular_array_contains(items, used, value)) { + items[used++] = value; + } + } + items[used] = NULL; + *out = used > 0 ? items : NULL; + return true; +} + +static bool angular_identifier_array(CBMArena *a, TSNode node, const char *source, + const char ***out) { + *out = NULL; + if (strcmp(ts_node_type(node), "array") != 0) { + return false; + } + uint32_t count = ts_node_named_child_count(node); + const char **items = + (const char **)cbm_arena_alloc(a, sizeof(const char *) * (count + NULL_TERM)); + if (!items) { + return false; + } + int used = 0; + for (uint32_t i = 0; i < count; i++) { + TSNode item = ts_node_named_child(node, i); + if (strcmp(ts_node_type(item), "identifier") != 0) { + return false; + } + char *value = cbm_node_text(a, item, source); + if (!value || !value[0]) { + return false; + } + if (!angular_array_contains(items, used, value)) { + items[used++] = value; + } + } + items[used] = NULL; + *out = used > 0 ? items : NULL; + return true; +} + +static bool angular_parse_object(CBMArena *a, TSNode object, const char *source, + angular_metadata_t *meta) { + enum { + ANGULAR_SEEN_SELECTOR = 1 << 0, + ANGULAR_SEEN_STANDALONE = 1 << 1, + ANGULAR_SEEN_TEMPLATE_URL = 1 << 2, + ANGULAR_SEEN_STYLE_URLS = 1 << 3, + ANGULAR_SEEN_IMPORTS = 1 << 4, + }; + int seen = 0; + uint32_t count = ts_node_named_child_count(object); + for (uint32_t i = 0; i < count; i++) { + TSNode pair = ts_node_named_child(object, i); + if (strcmp(ts_node_type(pair), "pair") != 0) { + return false; /* spread/computed entries can override literal fields */ + } + TSNode key_node = ts_node_child_by_field_name(pair, TS_FIELD("key")); + TSNode value_node = ts_node_child_by_field_name(pair, TS_FIELD("value")); + if (ts_node_is_null(key_node) || ts_node_is_null(value_node)) { + return false; + } + char *key = cbm_node_text(a, key_node, source); + if (!key || !key[0]) { + return false; + } + size_t key_len = strlen(key); + if (key_len >= CBM_QUOTE_PAIR && (key[0] == '\'' || key[0] == '"') && + key[key_len - SKIP_ONE] == key[0]) { + key = cbm_arena_strndup(a, key + SKIP_ONE, key_len - CBM_QUOTE_PAIR); + } + + int flag = 0; + if (strcmp(key, "selector") == 0) { + flag = ANGULAR_SEEN_SELECTOR; + } else if (strcmp(key, "standalone") == 0) { + flag = ANGULAR_SEEN_STANDALONE; + } else if (strcmp(key, "templateUrl") == 0) { + flag = ANGULAR_SEEN_TEMPLATE_URL; + } else if (strcmp(key, "styleUrls") == 0) { + flag = ANGULAR_SEEN_STYLE_URLS; + } else if (strcmp(key, "imports") == 0) { + flag = ANGULAR_SEEN_IMPORTS; + } else { + continue; + } + if ((seen & flag) != 0) { + return false; + } + seen |= flag; + + if (flag == ANGULAR_SEEN_SELECTOR) { + meta->selector = angular_string_literal(a, value_node, source); + if (!meta->selector) { + return false; + } + } else if (flag == ANGULAR_SEEN_STANDALONE) { + const char *value_kind = ts_node_type(value_node); + if (strcmp(value_kind, "true") != 0 && strcmp(value_kind, "false") != 0) { + return false; + } + meta->standalone = strcmp(value_kind, "true") == 0; + meta->standalone_set = true; + } else if (flag == ANGULAR_SEEN_TEMPLATE_URL) { + meta->template_url = angular_string_literal(a, value_node, source); + if (!meta->template_url) { + return false; + } + } else if (flag == ANGULAR_SEEN_STYLE_URLS) { + if (!angular_string_array(a, value_node, source, &meta->style_urls)) { + return false; + } + } else if (flag == ANGULAR_SEEN_IMPORTS && + !angular_identifier_array(a, value_node, source, &meta->imports)) { + return false; + } + } + return true; +} + +static bool angular_parse_decorator(CBMExtractCtx *ctx, TSNode decorator, + angular_metadata_t *meta) { + CBMArena *a = ctx->arena; + const char *source = ctx->source; + TSNode expr = + ts_node_named_child_count(decorator) > 0 ? ts_node_named_child(decorator, 0) : (TSNode){0}; + if (ts_node_is_null(expr)) { + return false; + } + TSNode fn = expr; + TSNode args = {0}; + if (strcmp(ts_node_type(expr), "call_expression") == 0) { + fn = ts_node_child_by_field_name(expr, TS_FIELD("function")); + args = ts_node_child_by_field_name(expr, TS_FIELD("arguments")); + } + const char *kind = angular_kind_from_decorator(ctx, fn); + if (!kind) { + return false; + } + memset(meta, 0, sizeof(*meta)); + meta->kind = kind; + if (ts_node_is_null(args) || ts_node_named_child_count(args) == 0) { + return true; + } + if (ts_node_named_child_count(args) != 1) { + return true; + } + TSNode object = ts_node_named_child(args, 0); + if (strcmp(ts_node_type(object), "object") != 0) { + return true; + } + angular_metadata_t parsed = {.kind = kind}; + if (angular_parse_object(a, object, source, &parsed)) { + *meta = parsed; + } + return true; +} + +static void extract_angular_metadata(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, + CBMDefinition *def) { + if (ctx->language != CBM_LANG_TYPESCRIPT && ctx->language != CBM_LANG_TSX) { + return; + } + angular_metadata_t found = {0}; + int matches = 0; + TSNode prev = ts_node_prev_sibling(node); + while (!ts_node_is_null(prev)) { + if (cbm_kind_in_set(prev, spec->decorator_node_types)) { + angular_metadata_t candidate = {0}; + if (angular_parse_decorator(ctx, prev, &candidate)) { + found = candidate; + matches++; + } + } else if (ts_node_is_named(prev)) { + break; + } + prev = ts_node_prev_sibling(prev); + } + uint32_t count = ts_node_child_count(node); + for (uint32_t i = 0; i < count; i++) { + TSNode child = ts_node_child(node, i); + if (!cbm_kind_in_set(child, spec->decorator_node_types)) { + continue; + } + angular_metadata_t candidate = {0}; + if (angular_parse_decorator(ctx, child, &candidate)) { + found = candidate; + matches++; + } + } + if (matches != 1) { + return; /* no Angular decorator, or multiple ambiguous Angular roles */ + } + def->angular_kind = found.kind; + if ((strcmp(found.kind, "component") == 0 || strcmp(found.kind, "directive") == 0)) { + def->angular_selector = found.selector; + } + def->angular_standalone = found.standalone; + def->angular_standalone_set = found.standalone_set; + if (strcmp(found.kind, "component") == 0) { + def->angular_template_url = found.template_url; + def->angular_style_urls = found.style_urls; + if (!found.standalone_set || found.standalone) { + def->angular_imports = found.imports; + } + } +} + /* Rust: two same-named functions guarded by mutually-exclusive #[cfg(...)] * attributes both parse as distinct function_item nodes and otherwise receive * the SAME qualified_name, so the second graph upsert silently overwrites the @@ -3844,6 +4288,7 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec def.is_exported = cbm_is_exported(name, ctx->language); def.base_classes = extract_base_classes(a, node, ctx->source, ctx->language); def.decorators = extract_decorators(a, node, ctx->source, ctx->language, spec); + extract_angular_metadata(ctx, node, spec, &def); def.docstring = extract_docstring(a, node, ctx->source, ctx->language); cbm_defs_push(&ctx->result->defs, a, def); @@ -4135,8 +4580,14 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node, def.decorators = extract_decorators(a, child, ctx->source, ctx->language, spec); extract_route_from_decorators(a, child, ctx->source, spec, &def.route_path, &def.route_method); if (def.route_path && (ctx->language == CBM_LANG_JAVA || ctx->language == CBM_LANG_KOTLIN)) { - const char *prefix = spring_class_route_prefix(a, class_node, ctx->source, spec); + const char *prefix = class_route_prefix(a, class_node, ctx->source, spec); def.route_path = join_route_paths(a, prefix, def.route_path); + } else if (def.route_path && ctx->language == CBM_LANG_CSHARP) { + def.route_path = + compose_aspnet_route(a, class_node, ctx->source, spec, name, def.route_path); + if (def.route_path) { + def.route_framework = "aspnet"; + } } def.docstring = extract_docstring(a, child, ctx->source, ctx->language); diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 2127b2259..adeb87419 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -7,9 +7,11 @@ */ #include "cypher/cypher.h" #include "store/store.h" +#include "foundation/constants.h" #include "foundation/platform.h" #include "foundation/limits.h" #include "foundation/log.h" +#include enum { CYP_BUF_16 = 16, @@ -29,6 +31,7 @@ enum { CYP_NODE_COLS = 4, /* columns per node var: name, qn, label, file */ CYP_EDGE_COLS = 3, /* columns per edge var: name, qn, label */ CYP_COL_BUF = 48, /* max column buffer (16 vars * 3 cols) */ + CYP_NODE_PROP_BUF = CBM_SZ_2K, CYP_FOUND_NONE = -1, /* search miss sentinel */ /* mask for ebuf ring buffer (8 entries) */ }; @@ -2092,17 +2095,17 @@ static const char *node_prop(const cbm_node_t *n, const char *prop, cbm_store_t * a single row (or an ORDER-BY comparison) reads several of these before any * of them is copied out, so returning one shared static buffer would alias * every column to the last value read. Mirrors edge_prop's rotation. */ - static _Thread_local char bufs[CYP_BUF_8][CBM_SZ_512]; + static _Thread_local char bufs[CYP_BUF_8][CYP_NODE_PROP_BUF]; static _Thread_local int buf_idx = 0; char *out = bufs[buf_idx]; buf_idx = (buf_idx + SKIP_ONE) % CYP_BUF_8; if (strcmp(prop, "start_line") == 0) { - snprintf(out, CBM_SZ_512, "%d", n->start_line); + snprintf(out, CYP_NODE_PROP_BUF, "%d", n->start_line); return out; } if (strcmp(prop, "end_line") == 0) { - snprintf(out, CBM_SZ_512, "%d", n->end_line); + snprintf(out, CYP_NODE_PROP_BUF, "%d", n->end_line); return out; } /* Virtual computed properties: in_degree/out_degree via CALLS edges. @@ -2112,7 +2115,7 @@ static const char *node_prop(const cbm_node_t *n, const char *prop, cbm_store_t int out_deg = 0; cbm_store_node_degree(store, n->id, &in_deg, &out_deg); int val = (strcmp(prop, "in_degree") == 0) ? in_deg : out_deg; - snprintf(out, CBM_SZ_512, "%d", val); + snprintf(out, CYP_NODE_PROP_BUF, "%d", val); return out; } /* Fall back to any value stored in the node's properties JSON — exposes the @@ -2120,7 +2123,7 @@ static const char *node_prop(const cbm_node_t *n, const char *prop, cbm_store_t * transitive_loop_depth, recursive) and any other persisted property to * WHERE/RETURN, e.g. WHERE n.loop_depth >= 2. */ if (n->properties_json && n->properties_json[0] == '{') { - const char *v = json_extract_prop(n->properties_json, prop, out, CBM_SZ_512); + const char *v = json_extract_prop(n->properties_json, prop, out, CYP_NODE_PROP_BUF); if (v && v[0]) { return v; } @@ -2139,16 +2142,17 @@ static const char *node_prop(const cbm_node_t *n, const char *prop, cbm_store_t const char *res = NULL; const char *rv = node_string_field(&full, prop); if (rv && rv[0]) { - snprintf(out, CBM_SZ_512, "%s", rv); + snprintf(out, CYP_NODE_PROP_BUF, "%s", rv); res = out; } else if (strcmp(prop, "start_line") == 0) { - snprintf(out, CBM_SZ_512, "%d", full.start_line); + snprintf(out, CYP_NODE_PROP_BUF, "%d", full.start_line); res = out; } else if (strcmp(prop, "end_line") == 0) { - snprintf(out, CBM_SZ_512, "%d", full.end_line); + snprintf(out, CYP_NODE_PROP_BUF, "%d", full.end_line); res = out; } else if (full.properties_json && full.properties_json[0] == '{') { - const char *jv = json_extract_prop(full.properties_json, prop, out, CBM_SZ_512); + const char *jv = + json_extract_prop(full.properties_json, prop, out, CYP_NODE_PROP_BUF); if (jv && jv[0]) { res = out; } @@ -2162,43 +2166,33 @@ static const char *node_prop(const cbm_node_t *n, const char *prop, cbm_store_t return ""; } -/* Extract a string value from JSON properties_json by key. - * Writes result to buf (up to buf_sz). Returns buf if found, "" otherwise. - * Handles both string values ("key":"value") and numeric values ("key":1.5). */ +/* Extract a value from properties JSON by key. Strings are returned decoded; + * every other JSON type is returned as compact JSON (arrays remain complete). */ static const char *json_extract_prop(const char *json, const char *key, char *buf, size_t buf_sz) { + buf[0] = '\0'; if (!json || !key) { - buf[0] = '\0'; return buf; } - /* Build search pattern: "key": */ - char pattern[CBM_SZ_256]; - snprintf(pattern, sizeof(pattern), "\"%s\":", key); - const char *p = strstr(json, pattern); - if (!p) { - buf[0] = '\0'; + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + if (!doc) { return buf; } - p += strlen(pattern); - /* Skip whitespace */ - while (*p == ' ' || *p == '\t') { - p++; + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *value = root && yyjson_is_obj(root) ? yyjson_obj_get(root, key) : NULL; + if (!value) { + yyjson_doc_free(doc); + return buf; } - if (*p == '"') { - /* String value */ - p++; - size_t i = 0; - while (*p && *p != '"' && i < buf_sz - SKIP_ONE) { - buf[i++] = *p++; - } - buf[i] = '\0'; + if (yyjson_is_str(value)) { + snprintf(buf, buf_sz, "%s", yyjson_get_str(value)); } else { - /* Numeric or other value */ - size_t i = 0; - while (*p && *p != ',' && *p != '}' && *p != ' ' && i < buf_sz - SKIP_ONE) { - buf[i++] = *p++; + char *serialized = yyjson_val_write(value, 0, NULL); + if (serialized) { + snprintf(buf, buf_sz, "%s", serialized); + free(serialized); } - buf[i] = '\0'; } + yyjson_doc_free(doc); return buf; } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index a0d8e2a91..a7fb662a7 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -394,6 +394,10 @@ static const tool_def_t TOOLS[] = { "loop — the hidden O(n^2) that loop_depth misses), alloc_in_loop (allocations/appends inside " "a loop), recursion_in_loop (a self-call inside a loop), unguarded_recursion (recursion with " "no conditionally-guarded base case), param_count and max_access_depth (structure smells). " + "Angular Class nodes expose angular_kind, selector, standalone, templateUrl, styleUrls, and " + "angular_imports; resolved standalone dependencies use IMPORTS edges with via=" + "\"angular_metadata\". Array/object properties are returned as compact JSON text because " + "query_graph row cells are strings. " "Find all hot-path candidates in one query, e.g. MATCH (f:Function) WHERE " "f.transitive_loop_depth >= 3 OR f.linear_scan_in_loop >= 1 RETURN f.qualified_name, " "f.transitive_loop_depth, f.linear_scan_in_loop ORDER BY f.transitive_loop_depth DESC. " @@ -2046,7 +2050,7 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_str( doc, root, "hint", "No nodes with this label. Available labels: Function, Method, Class, " - "Interface, Route, Variable, Module, Package, File, Folder."); + "Interface, Route, Asset, Variable, Module, Package, File, Folder."); } } @@ -4358,6 +4362,11 @@ static yyjson_doc *enrich_node_properties(yyjson_mut_doc *doc, yyjson_mut_val *o yyjson_mut_obj_add_int(doc, obj, k, yyjson_get_int(val)); } else if (yyjson_is_real(val)) { yyjson_mut_obj_add_real(doc, obj, k, yyjson_get_real(val)); + } else if (yyjson_is_arr(val) || yyjson_is_obj(val)) { + yyjson_mut_val *copy = yyjson_val_mut_copy(doc, val); + if (copy) { + yyjson_mut_obj_add_val(doc, obj, k, copy); + } } } return props_doc; /* caller frees after serialization */ diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 7ed132d98..27e32191c 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -237,24 +237,13 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca /* Build route QN and upsert Route node for HTTP/async edge. */ static int64_t create_svc_route_node(cbm_pipeline_ctx_t *ctx, const char *url, cbm_svc_kind_t svc, const char *method, const char *broker) { - char route_qn[CBM_ROUTE_QN_SIZE]; - const char *prefix; - char cpath[CBM_SZ_256]; - const char *qpath = url; - if (svc == CBM_SVC_HTTP) { - prefix = method ? method : "ANY"; - qpath = cbm_route_canon_path(url, cpath, sizeof(cpath)); - } else { - prefix = broker ? broker : "async"; - } - snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, qpath); - const char *rp; if (svc == CBM_SVC_HTTP) { - rp = method ? method : "{}"; - } else { - rp = broker ? broker : "{}"; + return cbm_gbuf_upsert_http_route(ctx->gbuf, url, method); } - return cbm_gbuf_upsert_node(ctx->gbuf, "Route", url, route_qn, "", 0, 0, rp); + char route_qn[CBM_ROUTE_QN_SIZE]; + const char *prefix = broker ? broker : "async"; + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, url); + return cbm_gbuf_upsert_node(ctx->gbuf, "Route", url, route_qn, "", 0, 0, prefix); } /* Insert an edge, splicing the call-site line (,"line":N) in before the closing @@ -440,6 +429,12 @@ static const cbm_gbuf_node_t *calls_find_source(cbm_pipeline_ctx_t *ctx, const c return src; } +static bool call_is_angular_http_client(const CBMCall *call) { + static const char prefix[] = "@angular/common/http.HttpClient."; + return call && call->callee_name && + strncmp(call->callee_name, prefix, sizeof(prefix) - SKIP_ONE) == 0; +} + /* Resolve one call and emit the appropriate edge. Returns 1 if resolved, 0 if not. */ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBMResolvedCallArray *lsp_calls, const char *rel, @@ -450,6 +445,33 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, return 0; } + if (call->asset_path && call_is_angular_http_client(call)) { + cbm_gbuf_emit_asset_load(ctx->gbuf, source_node, call); + return SKIP_ONE; + } + + /* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the service + * signal lives in the callee_name. The registry or LSP can mis-resolve such + * a call by its short method name (for example, a synthesized Angular + * HttpClient.post call can collide with its local wrapper named `post`). + * Classify the full service callee first because its target is a synthesized + * route node, not the locally resolved method. Mirrors pass_parallel.c. */ + cbm_svc_kind_t csvc = cbm_service_pattern_match(call->callee_name); + if (csvc == CBM_SVC_HTTP || csvc == CBM_SVC_ASYNC) { + const char *cu = call->first_string_arg; + bool chas_url = cu && cu[0] != '\0' && + (cu[0] == '/' || strstr(cu, "://") != NULL || + (csvc == CBM_SVC_ASYNC && strlen(cu) > PAIR_LEN)); + if (chas_url) { + cbm_resolution_t svc_res = {.qualified_name = call->callee_name, + .confidence = PC_SVC_PATTERN_CONF, + .strategy = "service_pattern", + .candidate_count = 0}; + emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, csvc, false); + return SKIP_ONE; + } + } + /* LSP-resolved calls take precedence over registry-textual matching. * Unique-tail fallbacks are JVM-only (see cbm_pipeline_lsp_allow_tail_match). */ bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang); @@ -471,30 +493,6 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, } } - /* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the service - * signal lives in the callee_name. The registry can mis-resolve such a call - * to a spurious builtin short-name match (e.g. `requests.get` -> - * `builtins.dict.get` via "get", strategy unique_name), which is non-empty - * and not an HTTP pattern, so BOTH the empty-resolution and resolved-QN - * service checks below miss it and the call is dropped. Detect it on the - * callee_name FIRST so the HTTP_CALLS/ASYNC_CALLS edge is emitted regardless - * (target is a synthesized route node, not the unindexed library). (#523) */ - cbm_svc_kind_t csvc = cbm_service_pattern_match(call->callee_name); - if (csvc == CBM_SVC_HTTP || csvc == CBM_SVC_ASYNC) { - const char *cu = call->first_string_arg; - bool chas_url = cu && cu[0] != '\0' && - (cu[0] == '/' || strstr(cu, "://") != NULL || - (csvc == CBM_SVC_ASYNC && strlen(cu) > PAIR_LEN)); - if (chas_url) { - cbm_resolution_t svc_res = {.qualified_name = call->callee_name, - .confidence = PC_SVC_PATTERN_CONF, - .strategy = "service_pattern", - .candidate_count = 0}; - emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, csvc, false); - return SKIP_ONE; - } - } - cbm_resolution_t res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn, imp_keys, imp_vals, imp_count); if (!res.qualified_name || res.qualified_name[0] == '\0') { @@ -511,6 +509,10 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, * Native `fetch()` (#856) belongs here too, not in the substring * tables above: it only counts as the global API once resolution has * already failed to find a local/imported `fetch` definition. */ + if (call->asset_path && cbm_service_pattern_is_global_fetch(call->callee_name)) { + cbm_gbuf_emit_asset_load(ctx->gbuf, source_node, call); + return SKIP_ONE; + } cbm_svc_kind_t esvc = cbm_service_pattern_match(call->callee_name); if (esvc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) { esvc = CBM_SVC_HTTP; diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index d1f506b5c..15a9dfc1b 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -24,6 +24,7 @@ enum { PD_JSON_FIELD_OVERHEAD = 6 }; #include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/limits.h" +#include "foundation/str_util.h" #include "cbm.h" #include "simhash/minhash.h" #include "semantic/ast_profile.h" @@ -243,6 +244,17 @@ static void append_json_str_array(char *buf, size_t bufsize, size_t *pos, const *pos = p; } +static void append_json_bool(char *buf, size_t bufsize, size_t *pos, const char *key, bool val) { + size_t required = strlen(key) + sizeof(",\"\":false") - SKIP_ONE; + if (*pos + required + PD_ESC_SPACE > bufsize) { + return; + } + int n = snprintf(buf + *pos, bufsize - *pos, ",\"%s\":%s", key, val ? "true" : "false"); + if (n > 0 && (size_t)n < bufsize - *pos) { + *pos += (size_t)n; + } +} + /* Build properties JSON for a definition node. */ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) { /* The complexity/loop/recursion metrics are only meaningful for executable @@ -290,6 +302,15 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) append_json_str_array(buf, bufsize, &pos, "param_types", def->param_types); append_json_string(buf, bufsize, &pos, "route_path", def->route_path); append_json_string(buf, bufsize, &pos, "route_method", def->route_method); + append_json_string(buf, bufsize, &pos, "route_framework", def->route_framework); + append_json_string(buf, bufsize, &pos, "angular_kind", def->angular_kind); + append_json_string(buf, bufsize, &pos, "selector", def->angular_selector); + if (def->angular_standalone_set) { + append_json_bool(buf, bufsize, &pos, "standalone", def->angular_standalone); + } + append_json_string(buf, bufsize, &pos, "templateUrl", def->angular_template_url); + append_json_str_array(buf, bufsize, &pos, "styleUrls", def->angular_style_urls); + append_json_str_array(buf, bufsize, &pos, "angular_imports", def->angular_imports); /* MinHash fingerprint — append if present and buffer has room. */ if (def->fingerprint && def->fingerprint_k > 0 && @@ -325,6 +346,30 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const int64_t node_id = cbm_gbuf_upsert_node( ctx->gbuf, def->label ? def->label : "Function", def->name, def->qualified_name, def->file_path ? def->file_path : rel, (int)def->start_line, (int)def->end_line, props); + if (node_id > 0 && def->route_path && def->route_path[0]) { + const char *method = def->route_method ? def->route_method : "ANY"; + char canonical_path[CBM_SZ_256]; + char route_qn[CBM_ROUTE_QN_SIZE]; + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method, + cbm_route_canon_path(def->route_path, canonical_path, sizeof(canonical_path))); + char route_props[CBM_SZ_256]; + if (def->route_framework) { + snprintf(route_props, sizeof(route_props), + "{\"method\":\"%s\",\"source\":\"decorator\",\"framework\":\"%s\"}", method, + def->route_framework); + } else { + snprintf(route_props, sizeof(route_props), + "{\"method\":\"%s\",\"source\":\"decorator\"}", method); + } + int64_t route_id = + cbm_gbuf_upsert_node(ctx->gbuf, "Route", def->route_path, route_qn, + def->file_path ? def->file_path : rel, 0, 0, route_props); + char escaped_handler[CBM_SZ_512]; + char handles_props[CBM_SZ_512]; + cbm_json_escape(escaped_handler, sizeof(escaped_handler), def->qualified_name); + snprintf(handles_props, sizeof(handles_props), "{\"handler\":\"%s\"}", escaped_handler); + cbm_gbuf_insert_edge(ctx->gbuf, node_id, route_id, "HANDLES", handles_props); + } /* Register callable symbols + every type-like container (Class/Struct/ * Interface/Enum/Type/Trait). Type-like defs must be in the registry so * `class Foo : IBar` (INHERITS), `impl Trait for S` (IMPLEMENTS), and method/ diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 5275cbc3c..3b5cc7e14 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -454,6 +454,17 @@ static void append_json_str_array(char *buf, size_t bufsize, size_t *pos, const *pos = p; } +static void append_json_bool(char *buf, size_t bufsize, size_t *pos, const char *key, bool val) { + size_t required = strlen(key) + sizeof(",\"\":false") - SKIP_ONE; + if (*pos + required + PP_ESC_SPACE > bufsize) { + return; + } + int n = snprintf(buf + *pos, bufsize - *pos, ",\"%s\":%s", key, val ? "true" : "false"); + if (n > 0 && (size_t)n < bufsize - *pos) { + *pos += (size_t)n; + } +} + static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) { /* Complexity/loop/recursion metrics are meaningful only for Function/Method. * Gate the block so the millions of Macro/Field/Variable/Class/Enum nodes @@ -499,6 +510,15 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) append_json_str_array(buf, bufsize, &pos, "param_types", def->param_types); append_json_string(buf, bufsize, &pos, "route_path", def->route_path); append_json_string(buf, bufsize, &pos, "route_method", def->route_method); + append_json_string(buf, bufsize, &pos, "route_framework", def->route_framework); + append_json_string(buf, bufsize, &pos, "angular_kind", def->angular_kind); + append_json_string(buf, bufsize, &pos, "selector", def->angular_selector); + if (def->angular_standalone_set) { + append_json_bool(buf, bufsize, &pos, "standalone", def->angular_standalone); + } + append_json_string(buf, bufsize, &pos, "templateUrl", def->angular_template_url); + append_json_str_array(buf, bufsize, &pos, "styleUrls", def->angular_style_urls); + append_json_str_array(buf, bufsize, &pos, "angular_imports", def->angular_imports); /* MinHash fingerprint — append if present and buffer has room. * Hex-encoded K=64 uint32 = 512 chars + key/quotes ≈ 520 chars. */ @@ -731,7 +751,13 @@ static void insert_def_into_gbuf(extract_worker_state_t *ws, const cbm_file_info snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", rm, cbm_route_canon_path(def->route_path, cpath, sizeof(cpath))); char rprops[CBM_SZ_256]; - snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", rm); + if (def->route_framework) { + snprintf(rprops, sizeof(rprops), + "{\"method\":\"%s\",\"source\":\"decorator\",\"framework\":\"%s\"}", rm, + def->route_framework); + } else { + snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", rm); + } int64_t route_id = cbm_gbuf_upsert_node(ws->local_gbuf, "Route", def->route_path, route_qn, def->file_path ? def->file_path : fi->rel_path, 0, 0, rprops); @@ -1598,23 +1624,20 @@ static void finalize_and_emit(cbm_gbuf_t *gbuf, int64_t src_id, int64_t tgt_id, } /* Build Route node QN and properties for HTTP/async service edges. */ -static int64_t build_service_route(cbm_gbuf_t *gbuf, const char *arg, const char *method, - const char *broker, cbm_svc_kind_t svc) { - char route_qn[CBM_ROUTE_QN_SIZE]; - const char *prefix; - char cpath[CBM_SZ_256]; - const char *qpath = arg; +static int64_t build_service_route(cbm_gbuf_t *gbuf, const cbm_gbuf_t *main_gbuf, const char *arg, + const char *method, const char *broker, cbm_svc_kind_t svc) { if (svc == CBM_SVC_HTTP) { - prefix = method ? method : "ANY"; - qpath = cbm_route_canon_path(arg, cpath, sizeof(cpath)); - } else { - prefix = broker ? broker : "async"; + int64_t existing = cbm_gbuf_find_http_route(main_gbuf, arg, method); + if (existing > 0) { + return existing; + } + return cbm_gbuf_upsert_http_route(gbuf, arg, method); } - snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, qpath); + char route_qn[CBM_ROUTE_QN_SIZE]; + const char *prefix = broker ? broker : "async"; + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, arg); char route_props[CBM_SZ_256]; - if (method) { - snprintf(route_props, sizeof(route_props), "{\"method\":\"%s\"}", method); - } else if (broker) { + if (broker) { snprintf(route_props, sizeof(route_props), "{\"broker\":\"%s\"}", broker); } else { snprintf(route_props, sizeof(route_props), "{}"); @@ -1623,16 +1646,17 @@ static int64_t build_service_route(cbm_gbuf_t *gbuf, const char *arg, const char } /* Emit HTTP_CALLS or ASYNC_CALLS edge via Route node. */ -static void emit_http_async_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const CBMCall *call, const cbm_resolution_t *res, - cbm_svc_kind_t svc, const char *arg) { +static void emit_http_async_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_t *main_gbuf, + const cbm_gbuf_node_t *source, const CBMCall *call, + const cbm_resolution_t *res, cbm_svc_kind_t svc, + const char *arg) { const char *edge_type = (svc == CBM_SVC_HTTP) ? "HTTP_CALLS" : "ASYNC_CALLS"; const char *method = (svc == CBM_SVC_HTTP) ? cbm_service_pattern_http_method(call->callee_name) : NULL; const char *broker = (svc == CBM_SVC_ASYNC) ? cbm_service_pattern_broker(res->qualified_name) : NULL; - int64_t route_id = build_service_route(gbuf, arg, method, broker, svc); + int64_t route_id = build_service_route(gbuf, main_gbuf, arg, method, broker, svc); char esc_c[CBM_SZ_256]; char esc_a[CBM_SZ_256]; @@ -1781,6 +1805,9 @@ static bool normalize_url_arg(const char *url, char *norm, int norm_sz) { /* Detect API paths in call arguments and create HTTP_CALLS edges. */ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call) { + if (call->asset_path) { + return; + } for (int ai = 0; ai < call->arg_count; ai++) { const CBMCallArg *ca = &call->args[ai]; const char *url = ca->value ? ca->value : ca->expr; @@ -2000,7 +2027,8 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, /* Also detect route registration by callee name suffix alone (handles unresolved * local variables like app.include_router where QN resolution fails). */ - if (svc == CBM_SVC_NONE && cbm_service_pattern_route_method(call->callee_name) != NULL) { + if (svc == CBM_SVC_NONE && !call->asset_path && + cbm_service_pattern_route_method(call->callee_name) != NULL) { svc = CBM_SVC_ROUTE_REG; } @@ -2031,7 +2059,8 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, bool has_topic = (arg && arg[0] != '\0' && svc == CBM_SVC_ASYNC && strlen(arg) > PP_ESC_SPACE); if ((svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) && (has_url || has_topic)) { - emit_http_async_service_edge(gbuf, source, call, res, svc, arg); + emit_http_async_service_edge(gbuf, main_gbuf, source, call, res, svc, arg); + return; } else if (svc == CBM_SVC_GRPC) { emit_grpc_edge(gbuf, source, call, res); } else if (svc == CBM_SVC_GRAPHQL) { @@ -2129,6 +2158,12 @@ static void lsp_idx_free_key(const char *key, void *value, void *ud) { free((char *)key); } +static bool call_is_angular_http_client(const CBMCall *call) { + static const char prefix[] = "@angular/common/http.HttpClient."; + return call && call->callee_name && + strncmp(call->callee_name, prefix, sizeof(prefix) - SKIP_ONE) == 0; +} + /* Resolve calls for one file and emit CALLS/HTTP_CALLS/ASYNC_CALLS edges. */ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result, const char *rel, const char *module_qn, const char **imp_keys, @@ -2188,6 +2223,11 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB continue; } + if (call->asset_path && call_is_angular_http_client(call)) { + cbm_gbuf_emit_asset_load(ws->local_edge_buf, source_node, call); + continue; + } + /* LSP-resolved calls take precedence over registry textual matching. * Same helper + same CBM_LSP_CONFIDENCE_FLOOR as the sequential * pipeline (pass_calls.c) — both paths must admit the same set of @@ -2300,13 +2340,15 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } if (!res.qualified_name || res.qualified_name[0] == '\0') { - if (cbm_service_pattern_route_method(call->callee_name) != NULL) { + if (!call->asset_path && cbm_service_pattern_route_method(call->callee_name) != NULL) { cbm_resolution_t fake_res = {.qualified_name = call->callee_name, .confidence = PP_HALF_CONF, .strategy = "callee_suffix"}; emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count, false); + } else if (call->asset_path && cbm_service_pattern_is_global_fetch(call->callee_name)) { + cbm_gbuf_emit_asset_load(ws->local_edge_buf, source_node, call); } else if (cbm_service_pattern_is_global_fetch(call->callee_name)) { /* Native `fetch()` (#856): only the global API once resolution * has failed to find a local/imported `fetch`. Call the low-level @@ -2318,8 +2360,8 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB cbm_resolution_t fake_res = {.qualified_name = call->callee_name, .confidence = PP_HALF_CONF, .strategy = "service_pattern"}; - emit_http_async_service_edge(ws->local_edge_buf, source_node, call, &fake_res, - CBM_SVC_HTTP, u); + emit_http_async_service_edge(ws->local_edge_buf, rc->main_gbuf, source_node, + call, &fake_res, CBM_SVC_HTTP, u); } } continue; @@ -2545,7 +2587,33 @@ static void resolve_def_decorators(resolve_ctx_t *rc, resolve_worker_state_t *ws } } -/* Resolve INHERITS + DECORATES + IMPLEMENTS for one file. */ +static void resolve_def_angular_imports(resolve_ctx_t *rc, resolve_worker_state_t *ws, + const CBMDefinition *def, const cbm_gbuf_node_t *node, + const char *mq, const char **ik, const char **iv, int ic) { + if (!def->angular_imports) { + return; + } + for (int i = 0; def->angular_imports[i]; i++) { + const char *target_qn = + resolve_as_class(rc->registry, def->angular_imports[i], mq, ik, iv, ic); + if (!target_qn) { + continue; + } + const cbm_gbuf_node_t *target = cbm_gbuf_find_by_qn(rc->main_gbuf, target_qn); + if (!target || target->id == node->id) { + continue; + } + char escaped[CBM_SZ_256]; + char props[CBM_SZ_512]; + cbm_json_escape(escaped, sizeof(escaped), def->angular_imports[i]); + snprintf(props, sizeof(props), "{\"local_name\":\"%s\",\"via\":\"angular_metadata\"}", + escaped); + cbm_gbuf_insert_edge(ws->local_edge_buf, node->id, target->id, "IMPORTS", props); + ws->semantic_resolved++; + } +} + +/* Resolve INHERITS + DECORATES + Angular imports + IMPLEMENTS for one file. */ static void resolve_file_semantic(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count) { @@ -2560,6 +2628,7 @@ static void resolve_file_semantic(resolve_ctx_t *rc, resolve_worker_state_t *ws, } resolve_def_inherits(rc, ws, def, node, module_qn, imp_keys, imp_vals, imp_count); resolve_def_decorators(rc, ws, def, node, module_qn, imp_keys, imp_vals, imp_count); + resolve_def_angular_imports(rc, ws, def, node, module_qn, imp_keys, imp_vals, imp_count); } for (int t = 0; t < result->impl_traits.count; t++) { CBMImplTrait *it = &result->impl_traits.items[t]; diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 664c8252c..8c453a17c 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -34,11 +34,100 @@ enum { #include "graph_buffer/graph_buffer.h" #include "foundation/log.h" +#include #include #include +#include bool cbm_service_pattern_is_http_route_literal(const char *literal, const char *callee_name); +static const char *asset_extension(const char *path) { + const char *slash = strrchr(path, '/'); + const char *dot = strrchr(path, '.'); + return dot && (!slash || dot > slash) && dot[SKIP_ONE] ? dot + SKIP_ONE : ""; +} + +static bool asset_ext_in(const char *ext, const char *const *extensions) { + for (int i = 0; extensions[i]; i++) { + if (strcasecmp(ext, extensions[i]) == 0) { + return true; + } + } + return false; +} + +static const char *asset_type(const char *extension) { + static const char *const data[] = {"json", "yaml", "yml", "toml", "csv", "xml", NULL}; + static const char *const images[] = {"png", "jpg", "jpeg", "gif", "svg", + "webp", "avif", "ico", NULL}; + static const char *const styles[] = {"css", "scss", "sass", "less", NULL}; + static const char *const scripts[] = {"js", "mjs", "cjs", "ts", NULL}; + static const char *const fonts[] = {"woff", "woff2", "ttf", "otf", "eot", NULL}; + static const char *const media[] = {"mp3", "wav", "ogg", "mp4", "webm", NULL}; + static const char *const documents[] = {"html", "htm", "pdf", "txt", "md", NULL}; + if (asset_ext_in(extension, data)) { + return "data"; + } + if (asset_ext_in(extension, images)) { + return "image"; + } + if (asset_ext_in(extension, styles)) { + return "stylesheet"; + } + if (asset_ext_in(extension, scripts)) { + return "script"; + } + if (asset_ext_in(extension, fonts)) { + return "font"; + } + if (asset_ext_in(extension, media)) { + return "media"; + } + if (asset_ext_in(extension, documents)) { + return "document"; + } + return "other"; +} + +int64_t cbm_gbuf_upsert_asset(cbm_gbuf_t *gb, const char *asset_path) { + if (!gb || !asset_path || asset_path[0] != '/') { + return 0; + } + char asset_qn[CBM_ROUTE_QN_SIZE]; + int n = snprintf(asset_qn, sizeof(asset_qn), "__asset__%s", asset_path); + if (n <= 0 || n >= (int)sizeof(asset_qn)) { + return 0; + } + const char *extension = asset_extension(asset_path); + char esc_path[CBM_SZ_1K]; + cbm_json_escape(esc_path, sizeof(esc_path), asset_path); + char properties[CBM_SZ_2K]; + snprintf(properties, sizeof(properties), + "{\"path\":\"%s\",\"extension\":\"%s\",\"asset_type\":\"%s\"," + "\"source\":\"programmatic\"}", + esc_path, extension, asset_type(extension)); + return cbm_gbuf_upsert_node(gb, "Asset", asset_path, asset_qn, "", 0, 0, properties); +} + +void cbm_gbuf_emit_asset_load(cbm_gbuf_t *gb, const cbm_gbuf_node_t *source, const CBMCall *call) { + if (!gb || !source || !call || !call->asset_path) { + return; + } + int64_t asset_id = cbm_gbuf_upsert_asset(gb, call->asset_path); + if (asset_id <= 0) { + return; + } + char esc_callee[CBM_SZ_256]; + char esc_path[CBM_SZ_1K]; + cbm_json_escape(esc_callee, sizeof(esc_callee), call->callee_name ? call->callee_name : ""); + cbm_json_escape(esc_path, sizeof(esc_path), call->asset_path); + char properties[CBM_SZ_2K]; + snprintf(properties, sizeof(properties), + "{\"callee\":\"%s\",\"asset_path\":\"%s\",\"via\":\"programmatic\",\"line\":%d}", + esc_callee, esc_path, call->start_line); + cbm_gbuf_insert_edge(gb, source->id, asset_id, "LOADS_ASSET", properties); +} + /* True for characters that may appear in a ":name" route parameter. */ static inline bool is_route_ident_char(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; @@ -155,6 +244,133 @@ static const char *json_extract(const char *json, const char *key, char *buf, in return buf; } +static bool route_is_aspnet(const cbm_gbuf_node_t *route) { + return route && route->label && strcmp(route->label, "Route") == 0 && route->properties_json && + strstr(route->properties_json, "\"framework\":\"aspnet\"") != NULL; +} + +static bool route_has_handler(const cbm_gbuf_t *gb, const cbm_gbuf_node_t *route) { + if (!route) { + return false; + } + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + return cbm_gbuf_find_edges_by_target_type(gb, route->id, "HANDLES", &edges, &edge_count) == 0 && + edge_count > 0; +} + +static int aspnet_path_match_score(const char *producer, const char *consumer) { + const char *producer_at = producer; + const char *consumer_at = consumer; + int parameter_substitutions = 0; + while (*producer_at || *consumer_at) { + if ((*producer_at == '/') != (*consumer_at == '/')) { + return -1; + } + if (*producer_at == '/') { + producer_at++; + consumer_at++; + } + const char *producer_end = strchr(producer_at, '/'); + const char *consumer_end = strchr(consumer_at, '/'); + size_t producer_len = + producer_end ? (size_t)(producer_end - producer_at) : strlen(producer_at); + size_t consumer_len = + consumer_end ? (size_t)(consumer_end - consumer_at) : strlen(consumer_at); + bool producer_parameter = + producer_len == PAIR_LEN && producer_at[0] == '{' && producer_at[SKIP_ONE] == '}'; + if (consumer_len == 0 || + (!producer_parameter && (producer_len != consumer_len || + strncasecmp(producer_at, consumer_at, producer_len) != 0))) { + return -1; + } + bool consumer_parameter = + consumer_len == PAIR_LEN && consumer_at[0] == '{' && consumer_at[SKIP_ONE] == '}'; + if (producer_parameter && !consumer_parameter) { + parameter_substitutions++; + } + if (!producer_end || !consumer_end) { + return producer_end == NULL && consumer_end == NULL ? parameter_substitutions : -1; + } + producer_at = producer_end; + consumer_at = consumer_end; + } + return parameter_substitutions; +} + +static int aspnet_route_match_score(const cbm_gbuf_node_t *route, const char *method, + const char *canonical_path) { + if (!route_is_aspnet(route) || !route->name) { + return -1; + } + char route_method[CBM_SZ_16]; + if (!json_extract(route->properties_json, "method", route_method, sizeof(route_method)) || + strcmp(route_method, method) != 0) { + return -1; + } + char producer_path[CBM_SZ_512]; + cbm_route_canon_path(route->name, producer_path, sizeof(producer_path)); + return aspnet_path_match_score(producer_path, canonical_path); +} + +int64_t cbm_gbuf_find_http_route(const cbm_gbuf_t *gb, const char *url, const char *method) { + if (!gb || !url || !url[0]) { + return 0; + } + const char *route_method = method && method[0] ? method : "ANY"; + char canonical_path[CBM_SZ_512]; + cbm_route_canon_path(url, canonical_path, sizeof(canonical_path)); + + char route_qn[CBM_ROUTE_QN_SIZE]; + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", route_method, canonical_path); + const cbm_gbuf_node_t *exact = cbm_gbuf_find_by_qn(gb, route_qn); + if (route_is_aspnet(exact)) { + return exact->id; + } + if (route_has_handler(gb, exact)) { + return exact->id; + } + + const cbm_gbuf_node_t **routes = NULL; + int route_count = 0; + if (cbm_gbuf_find_by_label(gb, "Route", &routes, &route_count) == 0) { + const cbm_gbuf_node_t *best = NULL; + int best_score = INT_MAX; + for (int i = 0; i < route_count; i++) { + int score = aspnet_route_match_score(routes[i], route_method, canonical_path); + if (score >= 0 && score < best_score) { + best = routes[i]; + best_score = score; + } + } + if (best) { + return best->id; + } + } + if (exact) { + return exact->id; + } + return 0; +} + +int64_t cbm_gbuf_upsert_http_route(cbm_gbuf_t *gb, const char *url, const char *method) { + int64_t existing = cbm_gbuf_find_http_route(gb, url, method); + if (existing > 0) { + return existing; + } + if (!gb || !url || !url[0]) { + return 0; + } + const char *route_method = method && method[0] ? method : "ANY"; + char canonical_path[CBM_SZ_512]; + cbm_route_canon_path(url, canonical_path, sizeof(canonical_path)); + char route_qn[CBM_ROUTE_QN_SIZE]; + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", route_method, canonical_path); + char route_props[CBM_SZ_64]; + snprintf(route_props, sizeof(route_props), "{\"method\":\"%s\"}", route_method); + return cbm_gbuf_upsert_node(gb, "Route", url, route_qn, "", 0, 0, route_props); +} + /* Visitor context for edge scanning */ typedef struct { cbm_gbuf_t *gb; @@ -191,16 +407,16 @@ static void route_edge_visitor(const cbm_gbuf_edge_t *edge, void *userdata) { const char *broker = json_extract(edge->properties_json, "broker", broker_buf, sizeof(broker_buf)); - /* Build Route QN */ - char route_qn[CBM_ROUTE_QN_SIZE]; if (strcmp(edge->type, "HTTP_CALLS") == 0) { - char cpath[CBM_SZ_256]; - snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method ? method : "ANY", - cbm_route_canon_path(url, cpath, sizeof(cpath))); - } else { - snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", broker ? broker : "async", url); + cbm_gbuf_upsert_http_route(ctx->gb, url, method); + ctx->created++; + return; } + /* Build async Route QN */ + char route_qn[CBM_ROUTE_QN_SIZE]; + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", broker ? broker : "async", url); + /* Build properties for Route node */ char route_props[CBM_SZ_256]; if (method) { @@ -457,7 +673,14 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun const cbm_gbuf_node_t *existing = cbm_gbuf_find_by_qn(gb, route_qn); char rprops[CBM_SZ_256]; - snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", method); + char framework[CBM_SZ_32]; + if (extract_json_prop(func->properties_json, "route_framework", framework, sizeof(framework))) { + snprintf(rprops, sizeof(rprops), + "{\"method\":\"%s\",\"source\":\"decorator\",\"framework\":\"%s\"}", method, + framework); + } else { + snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", method); + } int64_t route_id = cbm_gbuf_upsert_node(gb, "Route", path, route_qn, func->file_path ? func->file_path : "", 0, 0, rprops); diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c index 8a3bb01f0..368d3bf1f 100644 --- a/src/pipeline/pass_semantic.c +++ b/src/pipeline/pass_semantic.c @@ -93,7 +93,7 @@ static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, if (!imp->local_name || !imp->local_name[0] || !imp->module_path) { continue; } - char *target_qn = cbm_pipeline_fqn_module(ctx->project_name, imp->module_path); + char *target_qn = cbm_pipeline_resolve_module(ctx, rel_path, imp->module_path); const cbm_gbuf_node_t *target = cbm_gbuf_find_by_qn(ctx->gbuf, target_qn); free(target_qn); if (!target) { @@ -470,6 +470,25 @@ static void sem_process_def_edges(cbm_pipeline_ctx_t *ctx, const CBMDefinition * imp_count, decorates_count); } } + if (def->angular_imports) { + for (int i = 0; def->angular_imports[i]; i++) { + const char *target_qn = resolve_as_class(ctx->registry, def->angular_imports[i], + module_qn, imp_keys, imp_vals, imp_count); + if (!target_qn) { + continue; + } + const cbm_gbuf_node_t *target = cbm_gbuf_find_by_qn(ctx->gbuf, target_qn); + if (!target || target->id == node->id) { + continue; + } + char escaped[CBM_SZ_256]; + char props[CBM_SZ_512]; + cbm_json_escape(escaped, sizeof(escaped), def->angular_imports[i]); + snprintf(props, sizeof(props), "{\"local_name\":\"%s\",\"via\":\"angular_metadata\"}", + escaped); + cbm_gbuf_insert_edge(ctx->gbuf, node->id, target->id, "IMPORTS", props); + } + } } /* Get extraction result from cache or re-extract. Sets *owned=true if caller must free. */ diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index a3d806558..edca0ab38 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -34,6 +34,17 @@ * out_sz >= strlen(in) + 1 always suffices. Returns out. */ const char *cbm_route_canon_path(const char *in, char *out, size_t out_sz); +/* Reuse an ASP.NET producer Route when method + canonical path differ only by + * case; all other producer frameworks remain case-sensitive. Creates a normal + * consumer Route when no matching ASP.NET producer exists. */ +int64_t cbm_gbuf_find_http_route(const cbm_gbuf_t *gb, const char *url, const char *method); +int64_t cbm_gbuf_upsert_http_route(cbm_gbuf_t *gb, const char *url, const char *method); + +/* Materialize a referenced frontend asset and connect its consumer without + * routing it through HTTP_CALLS/cross-service matching. */ +int64_t cbm_gbuf_upsert_asset(cbm_gbuf_t *gb, const char *asset_path); +void cbm_gbuf_emit_asset_load(cbm_gbuf_t *gb, const cbm_gbuf_node_t *source, const CBMCall *call); + /* True when a graph node is a structural directory container (Folder/Project) * rather than a code node. In a directory-based-module language (Java/Go, see * cbm_lang_module_is_dir) a file's module QN equals its directory QN, so an diff --git a/src/store/store.c b/src/store/store.c index 67e46e970..4b30fe4c6 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -2324,19 +2324,19 @@ int cbm_store_node_neighbor_names(cbm_store_t *s, int64_t node_id, int limit, ch *out_callees = NULL; *callee_count = 0; - query_neighbor_names( - s->db, - "SELECT DISTINCT n.name FROM edges e JOIN nodes n ON e.source_id = n.id " - "WHERE e.target_id = ?1 AND e.type IN ('CALLS','HTTP_CALLS','ASYNC_CALLS') " - "ORDER BY n.name LIMIT ?2", - node_id, limit, out_callers, caller_count); - - query_neighbor_names( - s->db, - "SELECT DISTINCT n.name FROM edges e JOIN nodes n ON e.target_id = n.id " - "WHERE e.source_id = ?1 AND e.type IN ('CALLS','HTTP_CALLS','ASYNC_CALLS') " - "ORDER BY n.name LIMIT ?2", - node_id, limit, out_callees, callee_count); + query_neighbor_names(s->db, + "SELECT DISTINCT n.name FROM edges e JOIN nodes n ON e.source_id = n.id " + "WHERE e.target_id = ?1 " + "AND e.type IN ('CALLS','HTTP_CALLS','ASYNC_CALLS','LOADS_ASSET') " + "ORDER BY n.name LIMIT ?2", + node_id, limit, out_callers, caller_count); + + query_neighbor_names(s->db, + "SELECT DISTINCT n.name FROM edges e JOIN nodes n ON e.target_id = n.id " + "WHERE e.source_id = ?1 " + "AND e.type IN ('CALLS','HTTP_CALLS','ASYNC_CALLS','LOADS_ASSET') " + "ORDER BY n.name LIMIT ?2", + node_id, limit, out_callees, callee_count); return 0; } diff --git a/src/store/store.h b/src/store/store.h index 6603bd234..c7b414274 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -77,7 +77,8 @@ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *ou * Returns CBM_STORE_OK or CBM_STORE_ERR. */ int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *count); -/* Get caller/callee names for a node (CALLS/HTTP_CALLS/ASYNC_CALLS edges). +/* Get caller/callee names for a node + * (CALLS/HTTP_CALLS/ASYNC_CALLS/LOADS_ASSET edges). * Returns 0 on success. Caller must free each out_callers[i]/out_callees[i] * and the arrays themselves. */ int cbm_store_node_neighbor_names(cbm_store_t *s, int64_t node_id, int limit, char ***out_callers, diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 929e4d6a9..019fd472d 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -2548,8 +2548,8 @@ TEST(cypher_issue873_distinct_order_limit_dedupes_before_limit) { TEST(cypher_issue873_distinct_limit_dedupes_before_limit) { cbm_store_t *s = setup_cypher_store(); cbm_cypher_result_t r = {0}; - int rc = cbm_cypher_execute( - s, "MATCH (n) RETURN DISTINCT n.label AS label LIMIT 2", "test", 0, &r); + int rc = + cbm_cypher_execute(s, "MATCH (n) RETURN DISTINCT n.label AS label LIMIT 2", "test", 0, &r); ASSERT_EQ(rc, 0); ASSERT_NULL(r.error); ASSERT_EQ(r.row_count, 2); @@ -2563,8 +2563,8 @@ TEST(cypher_issue873_distinct_order_skip_limit_dedupes_before_skip) { cbm_store_t *s = setup_cypher_store(); cbm_cypher_result_t r = {0}; int rc = cbm_cypher_execute( - s, "MATCH (n) RETURN DISTINCT n.label AS label ORDER BY label SKIP 1 LIMIT 1", "test", - 0, &r); + s, "MATCH (n) RETURN DISTINCT n.label AS label ORDER BY label SKIP 1 LIMIT 1", "test", 0, + &r); ASSERT_EQ(rc, 0); ASSERT_NULL(r.error); ASSERT_EQ(r.row_count, 1); @@ -2617,23 +2617,28 @@ TEST(cypher_multi_prop_projection_no_alias) { .end_line = 42, .properties_json = "{\"complexity\":3,\"cognitive\":7,\"loop_count\":2," "\"loop_depth\":1,\"self_recursive\":false," - "\"transitive_loop_depth\":5,\"recursive\":true}"}; + "\"transitive_loop_depth\":5,\"recursive\":true," + "\"styleUrls\":[\"./a.scss\",\"./theme.css\"]," + "\"angular_imports\":[\"CommonModule\",\"SharedCard\"]}"}; cbm_store_upsert_node(s, &n); cbm_cypher_result_t r = {0}; int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.loop_depth, f.transitive_loop_depth, " - "f.cognitive, f.complexity, f.start_line, f.end_line", + "f.cognitive, f.complexity, f.start_line, f.end_line, " + "f.styleUrls, f.angular_imports", "test", 0, &r); ASSERT_EQ(rc, 0); ASSERT_EQ(r.row_count, 1); - ASSERT_EQ(r.col_count, 6); + ASSERT_EQ(r.col_count, 8); ASSERT_STR_EQ(r.rows[0][0], "1"); /* loop_depth — NOT the suffix transitive_loop_depth */ ASSERT_STR_EQ(r.rows[0][1], "5"); /* transitive_loop_depth */ ASSERT_STR_EQ(r.rows[0][2], "7"); /* cognitive */ ASSERT_STR_EQ(r.rows[0][3], "3"); /* complexity */ ASSERT_STR_EQ(r.rows[0][4], "10"); /* start_line (computed) */ ASSERT_STR_EQ(r.rows[0][5], "42"); /* end_line (computed) — distinct from start_line */ + ASSERT_STR_EQ(r.rows[0][6], "[\"./a.scss\",\"./theme.css\"]"); + ASSERT_STR_EQ(r.rows[0][7], "[\"CommonModule\",\"SharedCard\"]"); cbm_cypher_result_free(&r); cbm_store_close(s); PASS(); diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index ca4f24419..3ec26c44b 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -142,6 +142,38 @@ static int et_edge_present(const EtFile *files, int nfiles, const char *edge, in return got >= floor; } +static int et_edge_exact(const EtFile *files, int nfiles, const char *edge, int expected) { + EtProj lp; + cbm_store_t *store = et_index_files(&lp, files, nfiles); + int got = store ? cbm_store_count_edges_by_type(store, lp.project, edge) : -1; + if (got != expected) { + fprintf(stderr, " [ET-EDGE] FAIL %-20s count=%d expected=%d\n", edge, got, expected); + } + et_cleanup(&lp, store); + return got == expected; +} + +static int et_cross_service_trace_contains(const EtFile *files, int nfiles, const char *function, + const char *expected) { + EtProj lp; + cbm_store_t *store = et_index_files(&lp, files, nfiles); + char args[700]; + snprintf(args, sizeof(args), + "{\"function_name\":\"%s\",\"project\":\"%s\"," + "\"direction\":\"outbound\",\"mode\":\"cross_service\"}", + function, lp.project ? lp.project : ""); + char *response = + lp.srv ? cbm_mcp_handle_tool(lp.srv, "trace_path", args) : NULL; + int ok = response && strstr(response, expected) != NULL; + if (!ok) { + fprintf(stderr, " [ET-TRACE] missing %s in %s\n", expected, + response ? response : "(null)"); + } + free(response); + et_cleanup(&lp, store); + return ok; +} + enum { ET_ROUTE_ASSERT_MAX = 16 }; /* Assert the exact Route node set. Edge-count smoke tests cannot catch partial @@ -202,6 +234,148 @@ static int et_routes_exact(const EtFile *files, int nfiles, const char **routes) return ok; } +static const EtFile ET_ASSET_FILES[] = { + {"asset.service.ts", + "import { HttpClient } from '@angular/common/http';\n" + "import { inject } from '@angular/core';\n\n" + "export class AssetLoader {\n" + " private http = inject(HttpClient);\n" + " loadTranslation(lang: string) {\n" + " return this.http.get(`/assets/i18n/${lang}.json?cache=1`);\n" + " }\n" + "}\n\n" + "export class ApiService {\n" + " constructor(private http: HttpClient) {}\n" + " loadOrders() { return this.http.get('/api/v1/orders'); }\n" + "}\n\n" + "export class LocalService {\n" + " constructor(private client: LocalClient) {}\n" + " loadFakeAsset() { return this.client.get('/assets/not-http.json'); }\n" + "}\n\n" + "export function fetchManifest() { return fetch('./assets/config/app.json#v1'); }\n" + "export function fetchBackend() { return fetch('/api/native'); }\n" + "export function fetchMember(repo: Repository) {\n" + " return repo.fetch('/assets/member.json');\n" + "}\n" + "export function readFromDisk() { return readFile('/assets/disk.json'); }\n"}}; + +static cbm_store_t *et_index_parallel(EtProj *lp, const EtFile *meaningful, int n_mean); + +static int et_asset_model_ok(bool parallel) { + EtProj lp; + cbm_store_t *store = parallel ? et_index_parallel(&lp, ET_ASSET_FILES, 1) + : et_index_files(&lp, ET_ASSET_FILES, 1); + cbm_node_t *assets = NULL; + int asset_count = 0; + int ok = store != NULL; + int loads = store ? cbm_store_count_edges_by_type(store, lp.project, "LOADS_ASSET") : -1; + int http = store ? cbm_store_count_edges_by_type(store, lp.project, "HTTP_CALLS") : -1; + if (!store || cbm_store_find_nodes_by_label(store, lp.project, "Asset", &assets, + &asset_count) != CBM_STORE_OK) { + ok = 0; + } + if (asset_count != 2 || loads != 2 || http != 2) { + ok = 0; + } + + bool translation_found = false; + bool manifest_found = false; + for (int i = 0; i < asset_count; i++) { + const char *name = assets[i].name ? assets[i].name : ""; + if (strcmp(name, "/assets/i18n/{}.json") == 0) { + translation_found = true; + } else if (strcmp(name, "/assets/config/app.json") == 0) { + manifest_found = true; + } + if (!assets[i].properties_json || + strstr(assets[i].properties_json, "\"extension\":\"json\"") == NULL || + strstr(assets[i].properties_json, "\"asset_type\":\"data\"") == NULL) { + ok = 0; + } + } + if (!translation_found || !manifest_found) { + ok = 0; + } + + cbm_node_t *routes = NULL; + int route_count = 0; + if (!store || + cbm_store_find_nodes_by_label(store, lp.project, "Route", &routes, &route_count) != + CBM_STORE_OK || + route_count != 2) { + ok = 0; + } + for (int i = 0; i < route_count; i++) { + if (routes[i].name && strstr(routes[i].name, "/assets/")) { + ok = 0; + } + } + + cbm_node_t *consumers = NULL; + int consumer_count = 0; + if (!store || + cbm_store_find_nodes_by_name(store, lp.project, "loadTranslation", &consumers, + &consumer_count) != CBM_STORE_OK || + consumer_count != 1) { + ok = 0; + } else { + char args[1024]; + snprintf(args, sizeof(args), + "{\"qualified_name\":\"%s\",\"project\":\"%s\",\"include_neighbors\":true}", + consumers[0].qualified_name, lp.project); + char *snippet = cbm_mcp_handle_tool(lp.srv, "get_code_snippet", args); + if (!snippet || strstr(snippet, "/assets/i18n/{}.json") == NULL) { + ok = 0; + } + free(snippet); + + snprintf(args, sizeof(args), + "{\"function_name\":\"loadTranslation\",\"project\":\"%s\"," + "\"direction\":\"outbound\",\"mode\":\"cross_service\"}", + lp.project); + char *cross = cbm_mcp_handle_tool(lp.srv, "trace_path", args); + if (!cross || strstr(cross, "/assets/i18n/{}.json") != NULL) { + ok = 0; + } + free(cross); + + snprintf(args, sizeof(args), + "{\"function_name\":\"loadTranslation\",\"project\":\"%s\"," + "\"direction\":\"outbound\",\"edge_types\":[\"LOADS_ASSET\"]}", + lp.project); + char *explicit_trace = cbm_mcp_handle_tool(lp.srv, "trace_path", args); + if (!explicit_trace || strstr(explicit_trace, "/assets/i18n/{}.json") == NULL) { + ok = 0; + } + free(explicit_trace); + } + + if (asset_count > 0) { + char args[1024]; + snprintf(args, sizeof(args), "{\"qualified_name\":\"%s\",\"project\":\"%s\"}", + assets[0].qualified_name, lp.project); + char *snippet = cbm_mcp_handle_tool(lp.srv, "get_code_snippet", args); + if (!snippet || strstr(snippet, "assets/") == NULL) { + ok = 0; + } + free(snippet); + } + + if (!ok) { + fprintf(stderr, + " [ET-ASSET] FAIL parallel=%d assets=%d loads=%d http=%d routes=%d consumers=%d\n", + parallel ? 1 : 0, asset_count, loads, http, route_count, consumer_count); + for (int i = 0; i < route_count; i++) { + fprintf(stderr, " [ET-ASSET] route=%s\n", routes[i].name ? routes[i].name : ""); + } + } + cbm_store_free_nodes(consumers, consumer_count); + cbm_store_free_nodes(routes, route_count); + cbm_store_free_nodes(assets, asset_count); + et_cleanup(&lp, store); + return ok; +} + /* Index meaningful[] plus PARALLEL_PAD_FILES trivial pad files to force the * parallel pipeline path (MIN_FILES_FOR_PARALLEL = 50). */ enum { ET_PARALLEL_PAD = 52, ET_PAD_MAX = 68 /* 52 pad + 16 meaningful */ }; @@ -222,6 +396,82 @@ static cbm_store_t *et_index_parallel(EtProj *lp, const EtFile *meaningful, int return et_index_files(lp, files, n); } +static int et_angular_metadata_ok(bool parallel) { + static const EtFile f[] = {{"src/common.ts", "export class CommonModule {}\n"}, + {"src/shared-card.ts", "export class SharedCard {}\n"}, + {"src/orders.component.ts", + "import { Component } from '@angular/core';\n" + "import { CommonModule } from './common';\n" + "import { SharedCard } from './shared-card';\n" + "@Component({ selector: 'app-orders', standalone: true,\n" + " templateUrl: './orders.component.html',\n" + " styleUrls: ['./orders.component.scss'],\n" + " imports: [CommonModule, SharedCard] })\n" + "export class OrdersComponent {}\n"}}; + EtProj lp; + cbm_store_t *store = parallel ? et_index_parallel(&lp, f, 3) : et_index_files(&lp, f, 3); + int ok = store != NULL; + cbm_node_t *components = NULL; + int component_count = 0; + if (!store || + cbm_store_find_nodes_by_name(store, lp.project, "OrdersComponent", &components, + &component_count) != CBM_STORE_OK || + component_count != 1) { + ok = 0; + } else { + const char *props = components[0].properties_json; + if (!props || !strstr(props, "\"angular_kind\":\"component\"") || + !strstr(props, "\"selector\":\"app-orders\"") || + !strstr(props, "\"standalone\":true") || + !strstr(props, "\"templateUrl\":\"./orders.component.html\"") || + !strstr(props, "\"styleUrls\":[\"./orders.component.scss\"]") || + !strstr(props, "\"angular_imports\":[\"CommonModule\",\"SharedCard\"]")) { + ok = 0; + } + + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_source_type(store, components[0].id, "IMPORTS", &edges, + &edge_count) != CBM_STORE_OK || + edge_count != 2) { + ok = 0; + } else { + bool common_found = false; + bool card_found = false; + for (int i = 0; i < edge_count; i++) { + cbm_node_t target = {0}; + if (!edges[i].properties_json || + !strstr(edges[i].properties_json, "\"via\":\"angular_metadata\"") || + cbm_store_find_node_by_id(store, edges[i].target_id, &target) != CBM_STORE_OK) { + ok = 0; + continue; + } + common_found = + common_found || (target.name && strcmp(target.name, "CommonModule") == 0); + card_found = card_found || (target.name && strcmp(target.name, "SharedCard") == 0); + cbm_node_free_fields(&target); + } + if (!common_found || !card_found) { + ok = 0; + } + } + cbm_store_free_edges(edges, edge_count); + } + cbm_store_free_nodes(components, component_count); + et_cleanup(&lp, store); + return ok; +} + +TEST(angular_metadata_sequential) { + ASSERT_TRUE(et_angular_metadata_ok(false)); + PASS(); +} + +TEST(angular_metadata_parallel) { + ASSERT_TRUE(et_angular_metadata_ok(true)); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * HANDLES — route→handler across web frameworks * @@ -411,6 +661,36 @@ TEST(handles_aspnet_csharp) { PASS(); } +/* ASP.NET Core MVC — class and action attributes compose into concrete, + * versioned routes. Attribute lists may be split by unrelated authorization + * metadata, and [controller] must derive from the class rather than filename. */ +TEST(handles_aspnet_controller_csharp) { + static const char *routes[] = {"/api/v1/Orders", "/api/v1/Orders/{id}", + "/internal/v2/reports", NULL}; + static const EtFile f[] = { + {"Controllers.cs", + "[ApiController]\n" + "[ApiVersion(\"1.0\")]\n" + "[Route(\"api/v{version:apiVersion}/[controller]\")]\n" + "public class OrdersController {\n" + " [Authorize]\n" + " [HttpGet]\n" + " public string List() { return \"all\"; }\n" + " [HttpGet(\"{id}\")]\n" + " public string Get(int id) { return \"one\"; }\n" + "}\n" + "[ApiController]\n" + "[ApiVersion(\"2.0\")]\n" + "[Route(\"internal/v{version:apiVersion}/reports\")]\n" + "public class ReportController {\n" + " [HttpPost]\n" + " public string Create() { return \"created\"; }\n" + "}\n"}}; + ASSERT_TRUE(et_edge_present(f, 1, "HANDLES", 3)); + ASSERT_TRUE(et_routes_exact(f, 1, routes)); + PASS(); +} + /* Laravel (PHP) — Route facade whose QN contains "Laravel". * REAL BUG: internal/cbm/extract_calls.c:extract_handler_arg only accepts an * identifier/member_expression/selector_expression/attribute/field_expression as @@ -521,6 +801,171 @@ TEST(http_calls_axios_ts) { PASS(); } +/* Angular HttpClient — DI receiver identity and local template-string URL + * propagation should classify calls even though @angular/common/http is an + * external package with no indexed method definitions. */ +TEST(http_calls_angular_httpclient_ts) { + static const char *routes[] = {"/api/v1/orders", "/api/v1/orders/{}", NULL}; + static const EtFile f[] = { + {"order.service.ts", + "import { HttpClient } from '@angular/common/http';\n" + "import { inject } from '@angular/core';\n\n" + "export class OrderService {\n" + " constructor(private http: HttpClient, private config: Config) {}\n" + " list() {\n" + " return this.http.get(`${this.config.baseUrl}/api/v1/orders`);\n" + " }\n" + " create(id: string, payload: unknown) {\n" + " const apiUrl = `${this.config.baseUrl}/api/v1/orders/${id}?notify=true`;\n" + " return this.http.post(apiUrl, payload);\n" + " }\n" + "}\n\n" + "export class TranslationLoader {\n" + " private http = inject(HttpClient);\n" + " load(lang: string) {\n" + " return this.http.get(`/assets/i18n/${lang}.json`);\n" + " }\n" + "}\n"}}; + ASSERT_TRUE(et_edge_present(f, 1, "HTTP_CALLS", 2)); + ASSERT_TRUE(et_routes_exact(f, 1, routes)); + PASS(); +} + +TEST(http_calls_angular_http_wrapper_ts) { + static const char *routes[] = {"/api/v1/customers/{}", NULL}; + static const EtFile f[] = { + {"customer.service.ts", + "import { HttpClient } from '@angular/common/http';\n\n" + "export class CustomerService {\n" + " constructor(private http: HttpClient) {}\n" + " private post(payload: unknown, apiURL: string) {\n" + " return this.http.post(apiURL, payload);\n" + " }\n" + " save(id: string, payload: unknown) {\n" + " const apiURL = `/api/v1/customers/${id}`;\n" + " return this.post(payload, apiURL);\n" + " }\n" + "}\n"}}; + ASSERT_TRUE(et_edge_present(f, 1, "HTTP_CALLS", 1)); + ASSERT_TRUE(et_routes_exact(f, 1, routes)); + PASS(); +} + +TEST(frontend_assets_programmatic_sequential) { + ASSERT_TRUE(et_asset_model_ok(false)); + PASS(); +} + +TEST(frontend_assets_programmatic_parallel) { + ASSERT_TRUE(et_asset_model_ok(true)); + PASS(); +} + +TEST(http_calls_angular_aspnet_case_insensitive_convergence) { + static const char *routes[] = {"/api/v1/Orders/{orderId}", + "/api/v1/Orders/{orderId}/state/{newState}", + "/api/v1/Orders/states", + "/internal/v2/reports", NULL}; + static const EtFile f[] = { + {"Controllers.cs", + "[ApiController]\n" + "[ApiVersion(\"1.0\")]\n" + "[Route(\"api/v{version:apiVersion}/[controller]\")]\n" + "public class OrdersController {\n" + " [HttpGet(\"{orderId}\")]\n" + " public string Get(int orderId) { return \"one\"; }\n" + " [HttpGet(\"states\")]\n" + " public string GetStates() { return \"states\"; }\n" + " [HttpPost(\"{orderId}/state/{newState}\")]\n" + " public string SetState(int orderId, string newState) { return newState; }\n" + "}\n" + "[ApiController]\n" + "[ApiVersion(\"2.0\")]\n" + "[Route(\"internal/v{version:apiVersion}/reports\")]\n" + "public class ReportController {\n" + " [HttpPost]\n" + " public string Create() { return \"created\"; }\n" + "}\n"}, + {"order.service.ts", + "import { HttpClient } from '@angular/common/http';\n" + "export class OrderService {\n" + " constructor(private http: HttpClient) {}\n" + " loadOrder(id: string) { return this.http.get(`/api/v1/orders/${id}`); }\n" + " loadStates() { return this.http.get('/api/v1/orders/states'); }\n" + " finishOrder(id: string) {\n" + " return this.http.post(`/api/v1/orders/${id}/state/Finished`, {});\n" + " }\n" + "}\n"}}; + ASSERT_TRUE(et_edge_exact(f, 2, "HTTP_CALLS", 3)); + ASSERT_TRUE(et_edge_exact(f, 2, "HANDLES", 4)); + ASSERT_TRUE(et_edge_exact(f, 2, "DATA_FLOWS", 3)); + ASSERT_TRUE(et_routes_exact(f, 2, routes)); + ASSERT_TRUE(et_cross_service_trace_contains(f, 2, "loadOrder", "OrdersController.Get")); + PASS(); +} + +TEST(http_calls_angular_aspnet_convergence_parallel) { + static const EtFile f[] = { + {"OrdersController.cs", + "[ApiController]\n" + "[Route(\"api/v1/[controller]\")]\n" + "public class OrdersController {\n" + " [HttpGet(\"{orderId}\")]\n" + " public string Get(int orderId) { return \"one\"; }\n" + "}\n"}, + {"order.service.ts", + "import { HttpClient } from '@angular/common/http';\n" + "export class OrderService {\n" + " constructor(private http: HttpClient) {}\n" + " loadOrder(id: string) { return this.http.get(`/api/v1/orders/${id}`); }\n" + "}\n"}}; + EtProj lp; + cbm_store_t *store = et_index_parallel(&lp, f, 2); + cbm_node_t *routes = NULL; + int route_count = 0; + int route_rc = + store ? cbm_store_find_nodes_by_label(store, lp.project, "Route", &routes, &route_count) : -1; + int http = store ? cbm_store_count_edges_by_type(store, lp.project, "HTTP_CALLS") : -1; + int handles = store ? cbm_store_count_edges_by_type(store, lp.project, "HANDLES") : -1; + int flows = store ? cbm_store_count_edges_by_type(store, lp.project, "DATA_FLOWS") : -1; + int route_ok = route_rc == CBM_STORE_OK && route_count == 1 && routes[0].name && + strcmp(routes[0].name, "/api/v1/Orders/{orderId}") == 0; + if (!route_ok) { + fprintf(stderr, " [ET-ROUTE] parallel rc=%d count=%d http=%d handles=%d flows=%d\n", + route_rc, route_count, http, handles, flows); + for (int i = 0; i < route_count; i++) { + fprintf(stderr, " [ET-ROUTE] %s\n", routes[i].name ? routes[i].name : "(null)"); + } + } + cbm_store_free_nodes(routes, route_count); + et_cleanup(&lp, store); + ASSERT_TRUE(route_ok); + ASSERT_EQ(http, 1); + ASSERT_EQ(handles, 1); + ASSERT_EQ(flows, 1); + PASS(); +} + +TEST(http_calls_non_aspnet_routes_remain_case_sensitive) { + static const char *routes[] = {"/API/Items/{item_id}", "/api/items/Fixed", NULL}; + static const EtFile f[] = { + {"api.py", + "from fastapi import FastAPI\n" + "app = FastAPI()\n\n" + "@app.get('/API/Items/{item_id}')\n" + "def read_item(item_id: str):\n" + " return {'id': item_id}\n"}, + {"item.service.ts", + "import { HttpClient } from '@angular/common/http';\n" + "export class ItemService {\n" + " constructor(private http: HttpClient) {}\n" + " get() { return this.http.get('/api/items/Fixed'); }\n" + "}\n"}}; + ASSERT_TRUE(et_edge_exact(f, 2, "DATA_FLOWS", 0)); + ASSERT_TRUE(et_routes_exact(f, 2, routes)); + PASS(); +} + /* requests (Python) — already in test_lang_contract.c but uses different fixture; * this variant tests cross-file import resolution */ TEST(http_calls_requests_python) { @@ -1442,6 +1887,9 @@ TEST(override_go_interface) { * ══════════════════════════════════════════════════════════════════ */ SUITE(edge_types_probe) { + RUN_TEST(angular_metadata_sequential); + RUN_TEST(angular_metadata_parallel); + /* HANDLES — route→handler across web frameworks (8 frameworks) */ RUN_TEST(handles_flask_python); RUN_TEST(handles_fastapi_python); @@ -1452,6 +1900,7 @@ SUITE(edge_types_probe) { RUN_TEST(handles_spring_java); RUN_TEST(handles_spring_kotlin); RUN_TEST(handles_aspnet_csharp); + RUN_TEST(handles_aspnet_controller_csharp); RUN_TEST(handles_laravel_php); RUN_TEST(handles_rails_ruby); RUN_TEST(handles_actix_rust); @@ -1459,6 +1908,13 @@ SUITE(edge_types_probe) { /* HTTP_CALLS — outbound HTTP clients (9 libraries × languages) */ RUN_TEST(http_calls_fetch_js); RUN_TEST(http_calls_axios_ts); + RUN_TEST(http_calls_angular_httpclient_ts); + RUN_TEST(http_calls_angular_http_wrapper_ts); + RUN_TEST(frontend_assets_programmatic_sequential); + RUN_TEST(frontend_assets_programmatic_parallel); + RUN_TEST(http_calls_angular_aspnet_case_insensitive_convergence); + RUN_TEST(http_calls_angular_aspnet_convergence_parallel); + RUN_TEST(http_calls_non_aspnet_routes_remain_case_sensitive); RUN_TEST(http_calls_requests_python); RUN_TEST(http_calls_nethttp_go); RUN_TEST(http_calls_resttemplate_java); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index f8228e46a..fc90cb008 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -39,6 +39,43 @@ static int has_call(CBMFileResult *r, const char *callee) { return 0; } +static int has_call_route(CBMFileResult *r, const char *callee, const char *route) { + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *call = &r->calls.items[i]; + if (call->callee_name && strstr(call->callee_name, callee) && call->first_string_arg && + strcmp(call->first_string_arg, route) == 0) { + return 1; + } + } + return 0; +} + +static int has_call_route_from(CBMFileResult *r, const char *caller, const char *callee, + const char *route) { + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *call = &r->calls.items[i]; + if (call->enclosing_func_qn && strstr(call->enclosing_func_qn, caller) && + call->callee_name && strstr(call->callee_name, callee) && call->first_string_arg && + strcmp(call->first_string_arg, route) == 0) { + return 1; + } + } + return 0; +} + +static int has_call_asset_from(CBMFileResult *r, const char *caller, const char *callee, + const char *asset_path) { + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *call = &r->calls.items[i]; + if (call->enclosing_func_qn && strstr(call->enclosing_func_qn, caller) && + call->callee_name && strstr(call->callee_name, callee) && call->asset_path && + strcmp(call->asset_path, asset_path) == 0) { + return 1; + } + } + return 0; +} + /* Check if any import with the given module path exists. */ static int __attribute__((unused)) has_import(CBMFileResult *r, const char *path_substr) { for (int i = 0; i < r->imports.count; i++) { @@ -59,6 +96,31 @@ static int count_defs_with_label(CBMFileResult *r, const char *label) { return count; } +static int has_method_route(CBMFileResult *r, const char *name, const char *path, + const char *method) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *def = &r->defs.items[i]; + if (strcmp(def->label, "Method") == 0 && strcmp(def->name, name) == 0 && def->route_path && + strcmp(def->route_path, path) == 0 && def->route_method && + strcmp(def->route_method, method) == 0) { + return 1; + } + } + return 0; +} + +static int has_method_route_framework(CBMFileResult *r, const char *name, const char *framework) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *def = &r->defs.items[i]; + if (def->label && strcmp(def->label, "Method") == 0 && def->name && + strcmp(def->name, name) == 0 && def->route_framework && + strcmp(def->route_framework, framework) == 0) { + return 1; + } + } + return 0; +} + /* Convenience: extract, assert no error, return result. Caller frees. */ static CBMFileResult *extract(const char *src, CBMLanguage lang, const char *proj, const char *path) { @@ -122,6 +184,91 @@ TEST(extract_ts_factory_object_methods_issue341) { PASS(); } +TEST(extract_angular_httpclient_routes) { + CBMFileResult *r = + extract("import { HttpClient } from '@angular/common/http';\n" + "import { inject } from '@angular/core';\n" + "class ApiService {\n" + " constructor(private http: HttpClient, private config: Config) {}\n" + " load(id: string) {\n" + " const apiURL = `${this.config.baseUrl}/api/v1/orders/${id}?preview=true`;\n" + " return this.http.get(apiURL);\n" + " }\n" + "}\n" + "class FeatureService {\n" + " private client = inject(HttpClient);\n" + " load() { return this.client.get(`${this.config.baseUrl}/api/v1/features`); }\n" + "}\n" + "class LocalService {\n" + " constructor(private http: LocalClient) {}\n" + " load() { return this.http.get('/api/v1/local-only'); }\n" + "}\n" + "class AssetLoader {\n" + " private http = inject(HttpClient);\n" + " load(lang: string) { return this.http.get(`/assets/i18n/${lang}.json`); }\n" + "}\n", + CBM_LANG_TYPESCRIPT, "t", "api.service.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_call_route(r, "HttpClient.get", "/api/v1/orders/{}")); + ASSERT(has_call_route(r, "HttpClient.get", "/api/v1/features")); + ASSERT_FALSE(has_call_route(r, "HttpClient.get", "/api/v1/local-only")); + ASSERT_FALSE(has_call_route(r, "HttpClient.get", "/assets/i18n/{}.json")); + ASSERT(has_call_asset_from(r, "AssetLoader.load", "HttpClient.get", "/assets/i18n/{}.json")); + cbm_free_result(r); + PASS(); +} + +TEST(extract_angular_http_wrapper_routes) { + CBMFileResult *r = + extract("import { HttpClient } from '@angular/common/http';\n" + "class ApiService {\n" + " constructor(private http: HttpClient, private config: Config) {}\n" + " post(payload: unknown, apiURL: string) {\n" + " return this.http.post(apiURL, payload);\n" + " }\n" + " save(id: string, payload: unknown) {\n" + " const apiURL = `${this.config.baseUrl}/api/v1/orders/${id}`;\n" + " return this.post(payload, apiURL);\n" + " }\n" + " mutablePost(payload: unknown, apiURL: string) {\n" + " apiURL = '/api/v1/rewritten';\n" + " return this.http.post(apiURL, payload);\n" + " }\n" + " saveMutable(id: string, payload: unknown) {\n" + " const apiURL = `/api/v1/mutable/${id}`;\n" + " return this.mutablePost(payload, apiURL);\n" + " }\n" + " ambiguous(url: string, payload: unknown) {\n" + " this.http.get(url);\n" + " return this.http.post(url, payload);\n" + " }\n" + " saveAmbiguous(id: string, payload: unknown) {\n" + " const apiURL = `/api/v1/ambiguous/${id}`;\n" + " return this.ambiguous(apiURL, payload);\n" + " }\n" + " getAsset(assetURL: string) {\n" + " return this.http.get(assetURL);\n" + " }\n" + " loadTranslation(lang: string) {\n" + " const assetURL = `../assets/i18n/${lang}.json?cache=1`;\n" + " return this.getAsset(assetURL);\n" + " }\n" + "}\n", + CBM_LANG_TYPESCRIPT, "t", "api.service.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_call_route_from(r, "ApiService.save", "HttpClient.post", "/api/v1/orders/{}")); + ASSERT_FALSE( + has_call_route_from(r, "ApiService.saveMutable", "HttpClient.post", "/api/v1/mutable/{}")); + ASSERT_FALSE(has_call_route_from(r, "ApiService.saveAmbiguous", "HttpClient.post", + "/api/v1/ambiguous/{}")); + ASSERT(has_call_asset_from(r, "ApiService.loadTranslation", "HttpClient.get", + "/assets/i18n/{}.json")); + cbm_free_result(r); + PASS(); +} + /* --- C/C++ preprocessor macros become Macro nodes (#375) --- */ TEST(extract_c_macros_issue375) { CBMFileResult *r = extract("#define SIMPLE_MACRO 1\n" @@ -521,6 +668,30 @@ TEST(csharp_interface) { PASS(); } +TEST(csharp_aspnet_attribute_routes) { + CBMFileResult *r = extract("[ApiController]\n" + "[ApiVersion(\"1.0\")]\n" + "[Route(\"api/v{version:apiVersion}/[controller]\")]\n" + "public class OrdersController {\n" + " [Authorize]\n" + " [HttpGet]\n" + " public string List() => \"all\";\n" + " [HttpGet(\"{id}\")]\n" + " public string Get(int id) => \"one\";\n" + " [HttpPost(\"by-action/[action]\")]\n" + " public string Create() => \"created\";\n" + "}\n", + CBM_LANG_CSHARP, "t", "MisspelledFilename.cs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_method_route(r, "List", "/api/v1/Orders", "GET")); + ASSERT(has_method_route(r, "Get", "/api/v1/Orders/{id}", "GET")); + ASSERT(has_method_route(r, "Create", "/api/v1/Orders/by-action/Create", "POST")); + ASSERT(has_method_route_framework(r, "List", "aspnet")); + cbm_free_result(r); + PASS(); +} + /* --- Swift --- */ TEST(swift_class) { CBMFileResult *r = extract("class Vehicle {\n var speed: Int = 0\n func accelerate() { " @@ -2710,6 +2881,157 @@ static int decorators_contain(const CBMDefinition *d, const char *needle) { return 0; } +static int assert_string_array(const char *const *actual, const char *const *expected) { + ASSERT_NOT_NULL(actual); + int i = 0; + for (; expected[i]; i++) { + ASSERT_NOT_NULL(actual[i]); + ASSERT_STR_EQ(actual[i], expected[i]); + } + ASSERT_EQ(actual[i], NULL); + return 0; +} + +TEST(extract_angular_component_literal_metadata) { + CBMFileResult *r = extract("import { Component } from '@angular/core';\n" + "import { CommonModule } from '@angular/common';\n" + "import { SharedCard } from './shared-card';\n" + "@Component({\n" + " selector: 'app-orders',\n" + " standalone: true,\n" + " templateUrl: './orders.component.html',\n" + " styleUrls: ['./orders.component.scss', './theme.css', " + "'./orders.component.scss'],\n" + " imports: [CommonModule, SharedCard, CommonModule]\n" + "})\n" + "export class OrdersComponent {}\n", + CBM_LANG_TYPESCRIPT, "t", "orders.component.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *component = find_def_by_name(r, "OrdersComponent"); + ASSERT_NOT_NULL(component); + ASSERT_STR_EQ(component->angular_kind, "component"); + ASSERT_STR_EQ(component->angular_selector, "app-orders"); + ASSERT(component->angular_standalone_set); + ASSERT(component->angular_standalone); + ASSERT_STR_EQ(component->angular_template_url, "./orders.component.html"); + const char *style_urls[] = {"./orders.component.scss", "./theme.css", NULL}; + const char *imports[] = {"CommonModule", "SharedCard", NULL}; + ASSERT_EQ(assert_string_array(component->angular_style_urls, style_urls), 0); + ASSERT_EQ(assert_string_array(component->angular_imports, imports), 0); + cbm_free_result(r); + PASS(); +} + +TEST(extract_angular_definition_kinds) { + CBMFileResult *r = + extract("import { Directive, Injectable, NgModule, Pipe } from '@angular/core';\n" + "@Directive({ selector: '[trackClick]', standalone: false })\n" + "export class TrackClickDirective {}\n" + "@Pipe({ name: 'shortDate', standalone: true })\n" + "export class ShortDatePipe {}\n" + "@Injectable({ providedIn: 'root' })\n" + "export class OrdersService {}\n" + "@NgModule({ declarations: [TrackClickDirective] })\n" + "export class OrdersModule {}\n", + CBM_LANG_TYPESCRIPT, "t", "angular-kinds.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *directive = find_def_by_name(r, "TrackClickDirective"); + const CBMDefinition *pipe = find_def_by_name(r, "ShortDatePipe"); + const CBMDefinition *injectable = find_def_by_name(r, "OrdersService"); + const CBMDefinition *module = find_def_by_name(r, "OrdersModule"); + ASSERT_NOT_NULL(directive); + ASSERT_NOT_NULL(pipe); + ASSERT_NOT_NULL(injectable); + ASSERT_NOT_NULL(module); + ASSERT_STR_EQ(directive->angular_kind, "directive"); + ASSERT_STR_EQ(directive->angular_selector, "[trackClick]"); + ASSERT(directive->angular_standalone_set); + ASSERT_FALSE(directive->angular_standalone); + ASSERT_STR_EQ(pipe->angular_kind, "pipe"); + ASSERT(pipe->angular_standalone_set); + ASSERT(pipe->angular_standalone); + ASSERT_STR_EQ(injectable->angular_kind, "injectable"); + ASSERT_STR_EQ(module->angular_kind, "ng_module"); + cbm_free_result(r); + PASS(); +} + +TEST(extract_angular_rejects_dynamic_or_ambiguous_metadata) { + CBMFileResult *r = + extract("import { Component } from '@angular/core';\n" + "const config = { selector: 'dynamic-selector' };\n" + "const EXTRA_IMPORTS = [];\n" + "@Component(config)\n" + "export class DynamicConfigComponent {}\n" + "@Component({ selector: 'first', selector: 'second', standalone: true })\n" + "export class DuplicateMetadataComponent {}\n" + "@Component({ selector: 'safe', imports: [DynamicConfigComponent, " + "...EXTRA_IMPORTS] })\n" + "export class DynamicImportsComponent {}\n" + "@Component({ selector: 'legacy', standalone: false, " + "imports: [DynamicConfigComponent] })\n" + "export class NonStandaloneComponent {}\n", + CBM_LANG_TYPESCRIPT, "t", "dynamic.component.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *dynamic = find_def_by_name(r, "DynamicConfigComponent"); + const CBMDefinition *duplicate = find_def_by_name(r, "DuplicateMetadataComponent"); + const CBMDefinition *dynamic_imports = find_def_by_name(r, "DynamicImportsComponent"); + const CBMDefinition *non_standalone = find_def_by_name(r, "NonStandaloneComponent"); + ASSERT_NOT_NULL(dynamic); + ASSERT_NOT_NULL(duplicate); + ASSERT_NOT_NULL(dynamic_imports); + ASSERT_NOT_NULL(non_standalone); + ASSERT_STR_EQ(dynamic->angular_kind, "component"); + ASSERT_EQ(dynamic->angular_selector, NULL); + ASSERT_STR_EQ(duplicate->angular_kind, "component"); + ASSERT_EQ(duplicate->angular_selector, NULL); + ASSERT_FALSE(duplicate->angular_standalone_set); + ASSERT_STR_EQ(dynamic_imports->angular_kind, "component"); + ASSERT_EQ(dynamic_imports->angular_selector, NULL); + ASSERT_EQ(dynamic_imports->angular_imports, NULL); + ASSERT_STR_EQ(non_standalone->angular_selector, "legacy"); + ASSERT(non_standalone->angular_standalone_set); + ASSERT_FALSE(non_standalone->angular_standalone); + ASSERT_EQ(non_standalone->angular_imports, NULL); + cbm_free_result(r); + PASS(); +} + +TEST(extract_angular_namespace_decorator) { + CBMFileResult *r = extract("import * as ng from '@angular/core';\n" + "@ng.Component({ selector: 'app-namespace', standalone: true })\n" + "export class NamespaceComponent {}\n", + CBM_LANG_TYPESCRIPT, "t", "namespace.component.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *component = find_def_by_name(r, "NamespaceComponent"); + ASSERT_NOT_NULL(component); + ASSERT_STR_EQ(component->angular_kind, "component"); + ASSERT_STR_EQ(component->angular_selector, "app-namespace"); + ASSERT(component->angular_standalone_set); + ASSERT(component->angular_standalone); + cbm_free_result(r); + PASS(); +} + +TEST(extract_non_angular_component_decorator_ignored) { + CBMFileResult *r = extract("function Component(config: object) { return config; }\n" + "@Component({ selector: 'not-angular' })\n" + "export class LocalComponent {}\n", + CBM_LANG_TYPESCRIPT, "t", "local-component.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *component = find_def_by_name(r, "LocalComponent"); + ASSERT_NOT_NULL(component); + ASSERT_EQ(component->angular_kind, NULL); + ASSERT_EQ(component->angular_selector, NULL); + cbm_free_result(r); + PASS(); +} + /* Issue #382: Java Method nodes had empty decorators / signature. */ TEST(extract_java_method_annotations_issue382) { CBMFileResult *r = extract("public class C {\n" @@ -3508,6 +3830,8 @@ SUITE(extraction) { RUN_TEST(extract_r_box_use_imports_issue218); RUN_TEST(extract_r_dollar_call_issue219); RUN_TEST(extract_ts_factory_object_methods_issue341); + RUN_TEST(extract_angular_httpclient_routes); + RUN_TEST(extract_angular_http_wrapper_routes); RUN_TEST(extract_c_macros_issue375); RUN_TEST(extract_cpp_macros_issue375); RUN_TEST(extract_gdscript_issue186); @@ -3531,6 +3855,7 @@ SUITE(extraction) { RUN_TEST(ruby_module); RUN_TEST(csharp_class); RUN_TEST(csharp_interface); + RUN_TEST(csharp_aspnet_attribute_routes); RUN_TEST(swift_class); RUN_TEST(kotlin_function); RUN_TEST(kotlin_class); @@ -3719,6 +4044,11 @@ SUITE(extraction) { RUN_TEST(python_init_nested_module_qn); RUN_TEST(js_index_module_qn_not_collide_with_folder); RUN_TEST(python_regular_module_qn_unchanged); + RUN_TEST(extract_angular_component_literal_metadata); + RUN_TEST(extract_angular_definition_kinds); + RUN_TEST(extract_angular_rejects_dynamic_or_ambiguous_metadata); + RUN_TEST(extract_angular_namespace_decorator); + RUN_TEST(extract_non_angular_component_decorator_ignored); RUN_TEST(extract_java_method_annotations_issue382); RUN_TEST(extract_java_no_double_class_qn); RUN_TEST(extract_go_no_filename_in_module_qn); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index c072088c0..890221066 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -760,6 +760,8 @@ TEST(tool_search_graph_includes_node_properties) { ASSERT_NOT_NULL(strstr(inner, "signature")); ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); ASSERT_NOT_NULL(strstr(inner, "is_exported")); + ASSERT_NOT_NULL(strstr(inner, "\"styleUrls\":[\"./a.scss\",\"./theme.css\"]")); + ASSERT_NOT_NULL(strstr(inner, "\"angular_imports\":[\"CommonModule\",\"SharedCard\"]")); free(inner); free(resp); @@ -2799,7 +2801,9 @@ static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz) { n_hr.end_line = 5; n_hr.properties_json = "{\"signature\":\"func HandleRequest() error\"," "\"return_type\":\"error\"," - "\"is_exported\":true}"; + "\"is_exported\":true," + "\"styleUrls\":[\"./a.scss\",\"./theme.css\"]," + "\"angular_imports\":[\"CommonModule\",\"SharedCard\"]}"; int64_t id_hr = cbm_store_upsert_node(st, &n_hr); cbm_node_t n_po = {0}; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 1f2a7c48b..c2567ae4e 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1094,16 +1094,15 @@ TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) { cbm_store_t *s = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s); - /* (1) Genuine HTTP_CALLS survive under the guard (>= 3): - * - axios.get('/api/orders') -> 2 edges (recognized lib #523 callee bypass - * + detect_url_in_args), and - * - dev.load('/api/data') -> 1 edge via detect_url_in_args, which runs - * unconditionally after emit_service_edge's branch even when the plain - * fall-through is suppressed. + /* (1) Genuine HTTP_CALLS survive under the guard (exactly 2): + * - axios.get('/api/orders') -> 1 classified service edge, and + * - dev.load('/api/data') -> 1 edge via detect_url_in_args. + * Recognized service calls return after their classified edge so the generic + * arg fallback cannot create a duplicate ANY-method Route. * dev.load is the class the predicate-duplicating guard lost: `.load` is not * a route suffix and `dev` is not an HTTP lib, so it was dropped before - * emit_service_edge ran (RED on that guard: only axios's 2). */ - ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 3); + * emit_service_edge ran. */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 2); /* (2) The verb-suffix + route-path member calls keep their route * registrations (edge type CALLS -> a Route node named by the path). These * classify as route_registration on main, NOT HTTP_CALLS — Option A preserves