diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index c81311404..bd55d35c2 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -1144,6 +1144,14 @@ static char *gotemplate_string_child(CBMArena *a, TSNode parent, const char *sou return (char *)v; } +static TSNode find_call_arguments_node(TSNode node) { + TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); + if (ts_node_is_null(args)) { + args = cbm_find_child_by_kind(node, "argument_list"); + } + return args; +} + // Resolve a Go-template / Helm call to the referenced named template: // {{ template "x" . }} -> template_action, name is a string child // {{ include "x" . }} -> function_call(include), name is first string arg @@ -1163,10 +1171,7 @@ static char *gotemplate_callee(CBMArena *a, TSNode node, const char *source) { strcmp(fname, "tpl") != 0)) { return NULL; } - TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); - if (ts_node_is_null(args)) { - args = cbm_find_child_by_kind(node, "argument_list"); - } + TSNode args = find_call_arguments_node(node); if (ts_node_is_null(args)) { return NULL; } @@ -1713,7 +1718,7 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk call.is_method = true; } - TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); + TSNode args = find_call_arguments_node(node); if (!ts_node_is_null(args)) { call.first_string_arg = extract_url_or_topic_arg(ctx, args); if (call.first_string_arg && call.first_string_arg[0] == '/') { diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 147f60daa..fc2b5b90d 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -121,16 +121,17 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca } } -/* Insert an edge, using the shared CALLS finalizer so sequential containment - * and parallel full indexing serialize call-site args/line identically. Keep - * this restricted to CALLS: route/config edge props feed full-only predump - * passes, so altering them desyncs full vs incremental indexing. */ +/* Insert a call-site edge through the shared finalizer so sequential and + * parallel indexing serialize call args identically. Only plain CALLS carries + * line metadata; service/config edges use args for derived route/data-flow + * passes but keep their existing edge-specific fields as the stable contract. */ static void calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const char *type, char *props, size_t cap, const CBMCall *call) { - if (call && strcmp(type, "CALLS") == 0) { + if (call) { size_t len = strlen(props); if (len >= SKIP_ONE && props[len - SKIP_ONE] == '}') { - cbm_pipeline_close_call_edge_props(props, cap, len - SKIP_ONE, call, true); + bool include_line = type && strcmp(type, "CALLS") == 0; + cbm_pipeline_close_call_edge_props(props, cap, len - SKIP_ONE, call, include_line); } } cbm_gbuf_insert_edge(gbuf, src, tgt, type, props); diff --git a/src/pipeline/pipeline_delta.c b/src/pipeline/pipeline_delta.c index 0f47cf413..1b25dfa81 100644 --- a/src/pipeline/pipeline_delta.c +++ b/src/pipeline/pipeline_delta.c @@ -35,6 +35,8 @@ static const char cbm_delta_reason_missing_existing_ownership[] = "missing_exist static const char cbm_delta_reason_missing_file_metadata[] = "missing_file_metadata"; static const char cbm_delta_reason_preflight_error[] = CBM_PIPELINE_DELTA_REASON_PREFLIGHT_ERROR; static const char cbm_delta_reason_publish_error[] = "publish_error"; +static const char cbm_delta_reason_cross_file_node_qn_collision[] = + CBM_PIPELINE_DELTA_REASON_CROSS_FILE_NODE_QN_COLLISION; static const char cbm_delta_reason_rename_requires_full[] = "rename_requires_full"; static const char cbm_delta_reason_unresolved_edge_endpoint[] = "unresolved_edge_endpoint"; static const char cbm_delta_reason_unsupported_derived_view[] = "unsupported_derived_view"; @@ -875,6 +877,60 @@ static bool delta_existing_or_insert_ownership_supported( return true; } +int cbm_pipeline_file_delta_has_cross_file_node_qn_collision( + cbm_store_t *store, const cbm_pipeline_file_delta_t *delta, bool *out_collision) { + if (out_collision) { + *out_collision = false; + } + if (!store || !delta || !delta->delta.project || !delta->delta.rel_path || !out_collision) { + return CBM_STORE_ERR; + } + if (delta->change_kind == CBM_PIPELINE_DELTA_CHANGE_DELETE) { + return CBM_STORE_OK; + } + for (int i = 0; i < delta->delta.node_count; i++) { + const cbm_node_t *node = &delta->delta.nodes[i]; + if (!node->qualified_name || node->qualified_name[0] == '\0') { + continue; + } + cbm_node_t existing = {0}; + int rc = cbm_store_find_node_by_qn(store, delta->delta.project, node->qualified_name, + &existing); + if (rc == CBM_STORE_NOT_FOUND) { + continue; + } + if (rc != CBM_STORE_OK) { + cbm_node_free_fields(&existing); + return rc; + } + const char *node_file = node->file_path ? node->file_path : delta->delta.rel_path; + bool collision = existing.file_path && node_file && + !delta_field_matches(existing.file_path, node_file); + cbm_node_free_fields(&existing); + if (collision) { + *out_collision = true; + return CBM_STORE_OK; + } + } + return CBM_STORE_OK; +} + +static bool delta_cross_file_node_qns_supported(cbm_store_t *store, + const cbm_pipeline_file_delta_t *delta, + cbm_pipeline_file_delta_plan_t *plan) { + bool collision = false; + int rc = cbm_pipeline_file_delta_has_cross_file_node_qn_collision(store, delta, &collision); + if (rc != CBM_STORE_OK) { + delta_plan_set_fallback(plan, cbm_delta_reason_preflight_error); + return false; + } + if (collision) { + delta_plan_set_fallback(plan, cbm_delta_reason_cross_file_node_qn_collision); + return false; + } + return true; +} + static bool delta_path_in_batch(const char *path, const cbm_pipeline_file_delta_t *const *deltas, int delta_count) { if (!path || !*path || !deltas) { @@ -1279,6 +1335,9 @@ int cbm_pipeline_plan_file_delta(cbm_store_t *store, const cbm_pipeline_file_del if (!delta_existing_or_insert_ownership_supported(store, delta, out)) { return CBM_STORE_OK; } + if (!delta_cross_file_node_qns_supported(store, delta, out)) { + return CBM_STORE_OK; + } enum { CBM_DELTA_SINGLE_COUNT = 1 }; const cbm_pipeline_file_delta_t *single_delta[] = {delta}; if (!delta_inbound_edges_supported(store, delta, single_delta, CBM_DELTA_SINGLE_COUNT, out)) { @@ -1368,6 +1427,9 @@ int cbm_pipeline_plan_file_delta_batch(cbm_store_t *store, if (!delta_existing_or_insert_ownership_supported(store, delta, out)) { return CBM_STORE_OK; } + if (!delta_cross_file_node_qns_supported(store, delta, out)) { + return CBM_STORE_OK; + } if (!delta_inbound_edges_supported(store, delta, deltas, delta_count, out)) { return CBM_STORE_OK; } @@ -1563,6 +1625,12 @@ int cbm_pipeline_publish_overlay_file_delta_batch(cbm_store_t *store, } else if (strcmp(project, deltas[i]->delta.project) != 0) { return CBM_STORE_ERR; } + bool collision = false; + int rc = + cbm_pipeline_file_delta_has_cross_file_node_qn_collision(store, deltas[i], &collision); + if (rc != CBM_STORE_OK || collision) { + return rc == CBM_STORE_OK ? CBM_STORE_NOT_FOUND : rc; + } } int64_t overlay_generation = 0; diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index f8bd545e4..54c3502f2 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1577,6 +1577,21 @@ static int incr_try_overlay_upsert_route(cbm_pipeline_t *p, cbm_store_t *store, delta_ptrs[i] = &deltas[i]; } + for (int i = 0; i < changed_count; i++) { + bool collision = false; + rc = cbm_pipeline_file_delta_has_cross_file_node_qn_collision(store, &deltas[i], + &collision); + if (rc != CBM_STORE_OK || collision) { + const char *reason = + collision ? CBM_PIPELINE_DELTA_REASON_CROSS_FILE_NODE_QN_COLLISION + : CBM_PIPELINE_DELTA_REASON_PREFLIGHT_ERROR; + cbm_pipeline_set_publish_reason(p, reason); + cbm_log_info("incremental.overlay.fallback", "reason", reason, "rc", + itoa_buf_incr(rc)); + goto cleanup; + } + } + rc = cbm_store_latest_complete_index_generation(store, project, &base_generation); if (rc == CBM_STORE_OK) { CBM_PROF_START(t_overlay_publish); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 761f976c2..7ed00fb85 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -348,6 +348,7 @@ typedef struct { #define CBM_PIPELINE_DELTA_REASON_FRONTIER_TOO_LARGE "frontier_too_large" #define CBM_PIPELINE_DELTA_REASON_INBOUND_EDGES_REQUIRE_FULL "inbound_edges_require_full" #define CBM_PIPELINE_DELTA_REASON_PREFLIGHT_ERROR "preflight_error" +#define CBM_PIPELINE_DELTA_REASON_CROSS_FILE_NODE_QN_COLLISION "cross_file_node_qn_collision" /* Conservative default exact-delta caps. Larger affected sets fall back to the * containment path unless config opts into a benchmarked frontier size. */ @@ -440,6 +441,8 @@ int cbm_pipeline_build_file_delta_from_gbuf(const cbm_gbuf_t *gbuf, const char * const char *rel_path, int64_t generation, cbm_pipeline_file_delta_t *out); bool cbm_pipeline_delta_edge_type_is_recomputed(const char *type); +int cbm_pipeline_file_delta_has_cross_file_node_qn_collision( + cbm_store_t *store, const cbm_pipeline_file_delta_t *delta, bool *out_collision); int cbm_pipeline_file_delta_add_preserved_inbound_edges(cbm_store_t *store, cbm_pipeline_file_delta_t *delta, int *out_added); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 2405e2ad6..2362ad784 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -2011,9 +2011,10 @@ static int pipeline_dump_store_file_to_file(const char *src_path, const char *de return rc; } -static bool pipeline_store_has_edge_between_qns(const char *db_path, const char *project, - const char *source_qn, const char *type, - const char *target_qn) { +static bool pipeline_store_edge_between_qns_matches(const char *db_path, const char *project, + const char *source_qn, const char *type, + const char *target_qn, + const char *props_needle) { cbm_store_t *s = cbm_store_open_path(db_path); if (!s) { return false; @@ -2029,7 +2030,9 @@ static bool pipeline_store_has_edge_between_qns(const char *db_path, const char if (cbm_store_find_edges_by_source_type(s, src.id, type, &edges, &edge_count) == CBM_STORE_OK) { for (int i = 0; i < edge_count; i++) { - if (edges[i].target_id == tgt.id) { + if (edges[i].target_id == tgt.id && + (!props_needle || (edges[i].properties_json && + strstr(edges[i].properties_json, props_needle)))) { found = true; break; } @@ -2038,10 +2041,19 @@ static bool pipeline_store_has_edge_between_qns(const char *db_path, const char } } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); cbm_store_close(s); return found; } +static bool pipeline_store_has_edge_between_qns(const char *db_path, const char *project, + const char *source_qn, const char *type, + const char *target_qn) { + return pipeline_store_edge_between_qns_matches(db_path, project, source_qn, type, target_qn, + NULL); +} + static void pipeline_restore_workers_env(bool had_workers, const char *saved_workers) { if (had_workers) { cbm_setenv("CBM_WORKERS", saved_workers, 1); @@ -2497,8 +2509,19 @@ TEST(pipeline_parallel_env_access_matches_sequential) { files[0] = "src/env.c"; contents[0] = "#include \n\n" + "const char *cbm_safe_getenv(const char *key, char *buf, unsigned long cap, " + "void *err) {\n" + " (void)buf;\n" + " (void)cap;\n" + " (void)err;\n" + " return key;\n" + "}\n\n" "const char *load_temp(void) {\n" " return getenv(\"CBM_TEST_PARALLEL_ENV\");\n" + "}\n\n" + "const char *load_home(void) {\n" + " char buf[32];\n" + " return cbm_safe_getenv(\"HOME\", buf, sizeof(buf), NULL);\n" "}\n"; for (int i = 0; i < FILLER_FILE_COUNT; i++) { int rn = snprintf(filler_files[i], sizeof(filler_files[i]), "fillers/filler_%02d.c", i); @@ -2548,6 +2571,16 @@ TEST(pipeline_parallel_env_access_matches_sequential) { env_qn)); ASSERT_TRUE(pipeline_store_has_edge_between_qns(par_db, project, source_qn, "CONFIGURES", env_qn)); + char call_target_qn[CBM_SZ_512]; + n = snprintf(call_target_qn, sizeof(call_target_qn), "%s.src.env.cbm_safe_getenv", project); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(call_target_qn)); + ASSERT_TRUE(pipeline_store_edge_between_qns_matches(seq_db, project, source_qn, + "CONFIGURES", call_target_qn, + "\"args\":[")); + ASSERT_TRUE(pipeline_store_edge_between_qns_matches(par_db, project, source_qn, + "CONFIGURES", call_target_qn, + "\"args\":[")); char diff_err[CBM_SZ_8K] = {0}; int diff_rc = cbm_test_compare_canonical_graphs(seq_db, par_db, project, diff_err, @@ -3614,6 +3647,51 @@ static int pipeline_delta_store_qn_exists(cbm_store_t *s, const char *project, c return 0; } +TEST(pipeline_file_delta_detects_cross_file_node_qn_collision) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t existing = {.project = "test", + .label = "Class", + .name = "Thing", + .qualified_name = "test.src.store.store.Thing", + .file_path = "src/store/store.c", + .start_line = 1, + .end_line = 4, + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &existing), 0); + + cbm_node_t delta_node = {.project = "test", + .label = "Class", + .name = "Thing", + .qualified_name = "test.src.store.store.Thing", + .file_path = "src/store/store.h", + .start_line = 1, + .end_line = 1, + .properties_json = "{}"}; + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", .rel_path = "src/store/store.h", .node_count = 1}, + .nodes = &delta_node, + .change_kind = CBM_PIPELINE_DELTA_CHANGE_UPSERT, + }; + delta.delta.nodes = &delta_node; + + bool collision = false; + ASSERT_EQ(cbm_pipeline_file_delta_has_cross_file_node_qn_collision(s, &delta, &collision), + CBM_STORE_OK); + ASSERT_TRUE(collision); + + delta_node.file_path = "src/store/store.c"; + collision = true; + ASSERT_EQ(cbm_pipeline_file_delta_has_cross_file_node_qn_collision(s, &delta, &collision), + CBM_STORE_OK); + ASSERT_FALSE(collision); + + cbm_store_close(s); + PASS(); +} + static void pipeline_delta_attach_test_metadata(cbm_pipeline_file_delta_t *delta, cbm_file_hash_t *hash, cbm_file_state_t *state) { @@ -13186,6 +13264,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_file_delta_scratch_seed_preserves_structure_roots); RUN_TEST(pipeline_file_delta_scratch_seed_supports_external_endpoint_descriptor); RUN_TEST(pipeline_file_delta_descriptor_from_gbuf); + RUN_TEST(pipeline_file_delta_detects_cross_file_node_qn_collision); RUN_TEST(pipeline_file_delta_preserves_safe_inbound_edges_for_overlay); RUN_TEST(pipeline_file_delta_descriptor_marks_unsupported_edges); RUN_TEST(pipeline_file_delta_metadata_from_file);