diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index ddad886af..55596732d 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -416,6 +416,33 @@ static TSNode resolve_lean_func_name(TSNode node, TSNode name) { return cbm_find_child_by_kind(node, "ident"); } +// Haskell infix-form definition head (`a <+> b`, ``x `plus` y``): the defined +// name is the operator, not the left operand that is the head's first child. A +// backticked `infix_id` yields its inner variable (`plus`); a symbolic `operator` +// is returned as-is and parenthesised by cbm_func_name_node_text, matching the +// prefix form `(<+>) a b = ...` (#2440). +static TSNode resolve_haskell_infix_head_name(TSNode head) { + enum { INFIX_MIN_CHILDREN = 3, INFIX_OP_IDX = 1 }; + TSNode op = ts_node_child_by_field_name(head, TS_FIELD("operator")); + if (ts_node_is_null(op) && ts_node_child_count(head) >= INFIX_MIN_CHILDREN) { + op = ts_node_child(head, INFIX_OP_IDX); + } + if (!ts_node_is_null(op)) { + const char *ok = ts_node_type(op); + if (strcmp(ok, "operator") == 0) { + return op; + } + if (strcmp(ok, "infix_id") == 0) { + TSNode v = cbm_find_child_by_kind(op, "variable"); + if (!ts_node_is_null(v)) { + return v; + } + } + } + TSNode null_node = {0}; + return null_node; +} + // Haskell: resolve function name from first named child (variable/name). static TSNode resolve_haskell_func_name(TSNode node) { if (ts_node_named_child_count(node) > 0) { @@ -424,6 +451,9 @@ static TSNode resolve_haskell_func_name(TSNode node) { if (strcmp(hk, "variable") == 0 || strcmp(hk, "name") == 0) { return head; } + if (strcmp(hk, "infix") == 0) { + return resolve_haskell_infix_head_name(head); + } if (ts_node_named_child_count(head) > 0) { TSNode v = ts_node_named_child(head, 0); const char *vk = ts_node_type(v); diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 0a770804f..9e310bc6c 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1213,6 +1213,14 @@ char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source, if (text && lang == CBM_LANG_NIX) { cbm_nix_strip_attr_quotes(text); } + /* Haskell infix-form operator definition: `a <+> b = …` resolves to the bare + * `operator` node `<+>`, while the prefix form `(<+>) a b = …` is named by its + * `prefix_id` text `(<+>)`. Parenthesise here, not in the resolver, so both + * forms mint the same name and the def name and the call-scope QN, which both + * route through this function, cannot disagree (#2440). */ + if (text && lang == CBM_LANG_HASKELL && strcmp(ts_node_type(name_node), "operator") == 0) { + return cbm_arena_sprintf(a, "(%s)", text); + } return text; } diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 8eaae7a26..c86be43bf 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -2899,12 +2899,23 @@ static cyp_partial_t partial_or(cyp_partial_t l, cyp_partial_t r) { : CYP_PARTIAL_UNKNOWN; } -/* A condition whose alias is not bound yet is UNKNOWN; a literal-only LHS - * (func condition with no variable arg) has nothing to wait for. */ +static bool partial_alias_unbound(binding_t *b, const char *alias) { + return alias && !binding_get(b, alias) && !binding_get_edge(b, alias); +} + +/* A condition is UNKNOWN while any alias it reads is not bound yet: its own + * variable, and every function argument's -- a func condition is filed under + * its first argument's alias, so the b in coalesce(a.x, b.name) is only seen + * here. A literal-only LHS has nothing to wait for. */ static cyp_partial_t eval_condition_partial(const cbm_condition_t *c, binding_t *b) { - if (c->variable && !binding_get(b, c->variable) && !binding_get_edge(b, c->variable)) { + if (partial_alias_unbound(b, c->variable)) { return CYP_PARTIAL_UNKNOWN; } + for (int i = 0; i < c->arg_count; i++) { + if (partial_alias_unbound(b, c->args[i].variable)) { + return CYP_PARTIAL_UNKNOWN; + } + } return eval_condition(c, b) ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE; } diff --git a/src/daemon/application.c b/src/daemon/application.c index 22b6f0d1a..32b407743 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -449,8 +449,8 @@ static bool application_regular_db_exists(const char *project) { if (written <= 0 || (size_t)written >= sizeof(path)) { return false; } - struct stat status; - return stat(path, &status) == 0 && S_ISREG(status.st_mode); + cbm_path_info_t status; + return cbm_path_info_utf8(path, &status) == CBM_PATH_INFO_OK && status.is_regular; } static bool application_canonical_directory_exists(const char *path) { diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index f8d04c4c6..ac7c47b7f 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -522,14 +522,28 @@ static watcher_git_status_t git_repo_status(cbm_watcher_t *w, project_state_t *s * a `.git` file (the gitlink form used by linked worktrees and initialized * submodules). Deliberately a filesystem check, not a git invocation — the whole * point is to learn something `rev-parse` cannot tell us, because it walks up. */ +/* Preserve stat's link-following behavior while inspecting UTF-8 paths. */ +static int watcher_path_info(const char *path, cbm_path_info_t *info) { + int status = cbm_path_info_utf8(path, info); + if (status == CBM_PATH_INFO_OK && info->is_symlink) { + char canonical[CBM_SZ_4K]; + if (!cbm_canonical_path(path, canonical, sizeof(canonical))) { + return CBM_PATH_INFO_UNAVAILABLE; + } + status = cbm_path_info_utf8(canonical, info); + } + return status; +} + static bool git_has_own_dot_git(const char *root_path) { char path[CBM_SZ_4K]; int written = snprintf(path, sizeof(path), "%s/.git", root_path); if (written <= 0 || (size_t)written >= sizeof(path)) { return false; } - struct stat st; - return stat(path, &st) == 0 && (S_ISDIR(st.st_mode) || S_ISREG(st.st_mode)); + cbm_path_info_t info; + return watcher_path_info(path, &info) == CBM_PATH_INFO_OK && + (info.is_directory || info.is_regular); } /* Does the ancestor repository actually track anything inside this directory? @@ -990,13 +1004,17 @@ static root_status_t root_status(const char *root_path, int *out_errno) { if (!root_path) { return ROOT_UNCERTAIN; } - struct stat st; - if (stat(root_path, &st) == 0) { - /* Exists but is no longer a directory → the root directory is gone. */ - return S_ISDIR(st.st_mode) ? ROOT_PRESENT : ROOT_MISSING; + cbm_path_info_t info; + int status = watcher_path_info(root_path, &info); + if (status == CBM_PATH_INFO_OK) { + return info.is_directory ? ROOT_PRESENT : ROOT_MISSING; } +#ifdef _WIN32 + *out_errno = status == CBM_PATH_INFO_ABSENT ? ENOENT : EIO; +#else *out_errno = errno; - return cbm_watcher_root_missing_errno(errno) ? ROOT_MISSING : ROOT_UNCERTAIN; +#endif + return status == CBM_PATH_INFO_ABSENT ? ROOT_MISSING : ROOT_UNCERTAIN; } /* Sustained-absence window (seconds) before a missing root may be pruned. @@ -1338,8 +1356,8 @@ static void publish_status(project_state_t *s, bool scanned) { /* Init baseline for a project: check if git, get HEAD, count files */ static bool init_baseline(cbm_watcher_t *w, project_state_t *s) { - struct stat st; - if (stat(s->root_path, &st) != 0) { + cbm_path_info_t info; + if (watcher_path_info(s->root_path, &info) != CBM_PATH_INFO_OK || !info.is_directory) { cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path); s->baseline_done = true; s->is_git = false; diff --git a/tests/test_cypher.c b/tests/test_cypher.c index e9d65b31f..ec3afbfc2 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -1917,6 +1917,32 @@ TEST(cypher_exists_has_outgoing_calls) { ASSERT_EQ(rc, 0); ASSERT_EQ(r.row_count, 2); cbm_cypher_result_free(&r); + + rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE b.name <> \"SubmitOrder\" " + "RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 2); + cbm_cypher_result_free(&r); + + rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE b.name = \"SubmitOrder\" OR " + "NOT b.name = \"SubmitOrder\" RETURN count(*)", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "3"); + cbm_cypher_result_free(&r); + + rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WITH a, b WHERE NOT b.name = " + "\"SubmitOrder\" RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 2); + cbm_cypher_result_free(&r); + cbm_store_close(s); PASS(); } @@ -3378,6 +3404,86 @@ TEST(cypher_exec_where_mixed_alias_xor) { PASS(); } +TEST(cypher_exec_where_not_target_node) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = \"SubmitOrder\" " + "RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 2); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_not_target_relationship) { + cbm_store_t *s = setup_cypher_multi_edge_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[r:HTTP_CALLS]->(b) WHERE NOT r.url_path = " + "\"/health\" RETURN r.url_path", + "testproj", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "/api/orders"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_not_source_node) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE NOT a.name = " + "\"HandleOrder\" RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_not_target_node_count) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = " + "\"SubmitOrder\" RETURN count(*)", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "2"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +/* The early WHERE pass must wait for every alias a condition reads. A function + * condition is filed under its first argument's alias, so in + * coalesce(a.no_such_prop, b.name) the target b hid behind the bound a: the + * seed row read coalesce(NULL, ) as a definite mismatch and pruned + * every row before CALLS expansion, while the same predicate after WITH was + * right. */ +TEST(cypher_exec_where_func_arg_on_unbound_target) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE coalesce(a.no_such_prop, b.name) = " + "\"SubmitOrder\" RETURN a.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + TEST(cypher_exec_where_in) { cbm_store_t *s = setup_cypher_store(); cbm_cypher_result_t r = {0}; @@ -6243,6 +6349,11 @@ SUITE(cypher) { RUN_TEST(cypher_exec_where_mixed_alias_and); RUN_TEST(cypher_exec_where_mixed_alias_or); RUN_TEST(cypher_exec_where_mixed_alias_xor); + RUN_TEST(cypher_exec_where_not_target_node); + RUN_TEST(cypher_exec_where_not_target_relationship); + RUN_TEST(cypher_exec_where_not_source_node); + RUN_TEST(cypher_exec_where_not_target_node_count); + RUN_TEST(cypher_exec_where_func_arg_on_unbound_target); RUN_TEST(cypher_exec_where_in); RUN_TEST(cypher_exec_where_not_in); RUN_TEST(cypher_exec_where_is_null); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 7bfda63fc..e1c1e4491 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1777,6 +1777,33 @@ TEST(haskell_function) { PASS(); } +/* #2440: an infix-form definition (`a <+> b = ...`, ``x `plus` y = ...``) has + * no `name` field; its first named child is an `infix` node whose first child + * is the LEFT OPERAND, so the def was named `a` / `x`. It must be named after + * the operator, consistent with the prefix form `(<+>) a b = ...`. */ +TEST(haskell_infix_definition_name) { + CBMFileResult *r = extract("module M where\n" + "\n" + "(<+>) :: Int -> Int -> Int\n" + "a <+> b = a + b\n" + "\n" + "plus :: Int -> Int -> Int\n" + "x `plus` y = x + y\n" + "\n" + "f :: Int\n" + "f = 1 <+> 2\n", + CBM_LANG_HASKELL, "t", "M.hs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "f")); + ASSERT(has_def(r, "Function", "plus")); + ASSERT(has_def(r, "Function", "(<+>)")); + ASSERT_FALSE(has_def(r, "Function", "a")); + ASSERT_FALSE(has_def(r, "Function", "x")); + cbm_free_result(r); + PASS(); +} + /* --- OCaml --- */ TEST(ocaml_function) { CBMFileResult *r = @@ -13177,6 +13204,7 @@ SUITE(extraction) { RUN_TEST(elixir_function); RUN_TEST(elixir_call_string_argument); RUN_TEST(haskell_function); + RUN_TEST(haskell_infix_definition_name); RUN_TEST(ocaml_function); RUN_TEST(erlang_function); diff --git a/tests/test_watcher.c b/tests/test_watcher.c index e0b78cffc..8be483ca8 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -1103,8 +1103,8 @@ TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop) { watcher_windows_image_identity_t expected_git; ASSERT_TRUE(watcher_windows_capture_image_identity(fake_git, &expected_git)); - const char *old_path_raw = getenv("PATH"); - const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); + const char *old_path_raw = cbm_runtime_getenv("PATH"); + const char *old_marker_raw = cbm_runtime_getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); char *old_path = old_path_raw ? cbm_strdup(old_path_raw) : NULL; char *old_marker = old_marker_raw ? cbm_strdup(old_marker_raw) : NULL; size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0); @@ -1238,8 +1238,8 @@ TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop) { ASSERT_TRUE(watcher_test_self_image(self)); ASSERT_EQ(symlink(self, fake_git), 0); - const char *old_path_raw = getenv("PATH"); - const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); + const char *old_path_raw = cbm_runtime_getenv("PATH"); + const char *old_marker_raw = cbm_runtime_getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); char *old_path = old_path_raw ? strdup(old_path_raw) : NULL; char *old_marker = old_marker_raw ? strdup(old_marker_raw) : NULL; size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0); @@ -3995,7 +3995,41 @@ TEST(watcher_null_watch_count) { * SUITE * ══════════════════════════════════════════════════════════════════ */ +TEST(watcher_unicode_root_is_not_pruned_as_missing) { + char *parent = th_mktempdir("cbm_watch_utf8"); + ASSERT_NOT_NULL(parent); + char root[CBM_SZ_4K]; + snprintf(root, sizeof(root), "%s/\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E", parent); + ASSERT_EQ(th_mkdir_p(root), 0); + const char *saved = cbm_runtime_getenv("CBM_WATCHER_PRUNE_GRACE_S"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", "0", 1); + cbm_store_t *store = cbm_store_open_memory(); + cbm_store_upsert_project(store, "unicode-root", root); + cbm_watcher_t *watcher = cbm_watcher_new(store, NULL, NULL); + cbm_watcher_watch(watcher, "unicode-root", root); + for (int i = 0; i < 8; i++) { + cbm_watcher_poll_once(watcher); + } + cbm_project_t info = {0}; + bool kept = cbm_watcher_watch_count(watcher) == 1 && + cbm_store_get_project(store, "unicode-root", &info) == CBM_STORE_OK; + cbm_project_free_fields(&info); + cbm_watcher_free(watcher); + cbm_store_close(store); + if (saved_copy) { + cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", saved_copy, 1); + } else { + cbm_unsetenv("CBM_WATCHER_PRUNE_GRACE_S"); + } + free(saved_copy); + ASSERT_EQ(th_rmtree(parent), 0); + ASSERT_TRUE(kept); + PASS(); +} + SUITE(watcher) { + RUN_TEST(watcher_unicode_root_is_not_pruned_as_missing); /* Adaptive interval */ RUN_TEST(poll_interval_base); RUN_TEST(poll_interval_scaling);