Skip to content

Commit 9bf66dd

Browse files
committed
Merge main to pick up shared CI repairs
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2 parents 0b81154 + 72c3604 commit 9bf66dd

11 files changed

Lines changed: 288 additions & 23 deletions

File tree

‎internal/cbm/extract_defs.c‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,33 @@ static TSNode resolve_lean_func_name(TSNode node, TSNode name) {
416416
return cbm_find_child_by_kind(node, "ident");
417417
}
418418

419+
// Haskell infix-form definition head (`a <+> b`, ``x `plus` y``): the defined
420+
// name is the operator, not the left operand that is the head's first child. A
421+
// backticked `infix_id` yields its inner variable (`plus`); a symbolic `operator`
422+
// is returned as-is and parenthesised by cbm_func_name_node_text, matching the
423+
// prefix form `(<+>) a b = ...` (#2440).
424+
static TSNode resolve_haskell_infix_head_name(TSNode head) {
425+
enum { INFIX_MIN_CHILDREN = 3, INFIX_OP_IDX = 1 };
426+
TSNode op = ts_node_child_by_field_name(head, TS_FIELD("operator"));
427+
if (ts_node_is_null(op) && ts_node_child_count(head) >= INFIX_MIN_CHILDREN) {
428+
op = ts_node_child(head, INFIX_OP_IDX);
429+
}
430+
if (!ts_node_is_null(op)) {
431+
const char *ok = ts_node_type(op);
432+
if (strcmp(ok, "operator") == 0) {
433+
return op;
434+
}
435+
if (strcmp(ok, "infix_id") == 0) {
436+
TSNode v = cbm_find_child_by_kind(op, "variable");
437+
if (!ts_node_is_null(v)) {
438+
return v;
439+
}
440+
}
441+
}
442+
TSNode null_node = {0};
443+
return null_node;
444+
}
445+
419446
// Haskell: resolve function name from first named child (variable/name).
420447
static TSNode resolve_haskell_func_name(TSNode node) {
421448
if (ts_node_named_child_count(node) > 0) {
@@ -424,6 +451,9 @@ static TSNode resolve_haskell_func_name(TSNode node) {
424451
if (strcmp(hk, "variable") == 0 || strcmp(hk, "name") == 0) {
425452
return head;
426453
}
454+
if (strcmp(hk, "infix") == 0) {
455+
return resolve_haskell_infix_head_name(head);
456+
}
427457
if (ts_node_named_child_count(head) > 0) {
428458
TSNode v = ts_node_named_child(head, 0);
429459
const char *vk = ts_node_type(v);

‎internal/cbm/helpers.c‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,6 +1213,14 @@ char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source,
12131213
if (text && lang == CBM_LANG_NIX) {
12141214
cbm_nix_strip_attr_quotes(text);
12151215
}
1216+
/* Haskell infix-form operator definition: `a <+> b = …` resolves to the bare
1217+
* `operator` node `<+>`, while the prefix form `(<+>) a b = …` is named by its
1218+
* `prefix_id` text `(<+>)`. Parenthesise here, not in the resolver, so both
1219+
* forms mint the same name and the def name and the call-scope QN, which both
1220+
* route through this function, cannot disagree (#2440). */
1221+
if (text && lang == CBM_LANG_HASKELL && strcmp(ts_node_type(name_node), "operator") == 0) {
1222+
return cbm_arena_sprintf(a, "(%s)", text);
1223+
}
12161224
return text;
12171225
}
12181226

‎scripts/ci/codeql-retrace-vendored.sh‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ fi
8080
printf '%s\n' "$changed" |
8181
bash "$ROOT/scripts/ci/select-lanes.sh" --codeql-scope c-cpp |
8282
while IFS= read -r path; do
83+
# Native Windows Python writes CRLF; read removes only the LF.
84+
path="${path%$'\r'}"
8385
entrypoints=()
8486
case "$path" in
8587
internal/cbm/grammar_*.c | internal/cbm/ts_runtime.c)

‎src/cypher/cypher.c‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2899,12 +2899,23 @@ static cyp_partial_t partial_or(cyp_partial_t l, cyp_partial_t r) {
28992899
: CYP_PARTIAL_UNKNOWN;
29002900
}
29012901

2902-
/* A condition whose alias is not bound yet is UNKNOWN; a literal-only LHS
2903-
* (func condition with no variable arg) has nothing to wait for. */
2902+
static bool partial_alias_unbound(binding_t *b, const char *alias) {
2903+
return alias && !binding_get(b, alias) && !binding_get_edge(b, alias);
2904+
}
2905+
2906+
/* A condition is UNKNOWN while any alias it reads is not bound yet: its own
2907+
* variable, and every function argument's -- a func condition is filed under
2908+
* its first argument's alias, so the b in coalesce(a.x, b.name) is only seen
2909+
* here. A literal-only LHS has nothing to wait for. */
29042910
static cyp_partial_t eval_condition_partial(const cbm_condition_t *c, binding_t *b) {
2905-
if (c->variable && !binding_get(b, c->variable) && !binding_get_edge(b, c->variable)) {
2911+
if (partial_alias_unbound(b, c->variable)) {
29062912
return CYP_PARTIAL_UNKNOWN;
29072913
}
2914+
for (int i = 0; i < c->arg_count; i++) {
2915+
if (partial_alias_unbound(b, c->args[i].variable)) {
2916+
return CYP_PARTIAL_UNKNOWN;
2917+
}
2918+
}
29082919
return eval_condition(c, b) ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE;
29092920
}
29102921

‎src/daemon/application.c‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,8 +449,8 @@ static bool application_regular_db_exists(const char *project) {
449449
if (written <= 0 || (size_t)written >= sizeof(path)) {
450450
return false;
451451
}
452-
struct stat status;
453-
return stat(path, &status) == 0 && S_ISREG(status.st_mode);
452+
cbm_path_info_t status;
453+
return cbm_path_info_utf8(path, &status) == CBM_PATH_INFO_OK && status.is_regular;
454454
}
455455

456456
static bool application_canonical_directory_exists(const char *path) {

‎src/watcher/watcher.c‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -522,14 +522,28 @@ static watcher_git_status_t git_repo_status(cbm_watcher_t *w, project_state_t *s
522522
* a `.git` file (the gitlink form used by linked worktrees and initialized
523523
* submodules). Deliberately a filesystem check, not a git invocation — the whole
524524
* point is to learn something `rev-parse` cannot tell us, because it walks up. */
525+
/* Preserve stat's link-following behavior while inspecting UTF-8 paths. */
526+
static int watcher_path_info(const char *path, cbm_path_info_t *info) {
527+
int status = cbm_path_info_utf8(path, info);
528+
if (status == CBM_PATH_INFO_OK && info->is_symlink) {
529+
char canonical[CBM_SZ_4K];
530+
if (!cbm_canonical_path(path, canonical, sizeof(canonical))) {
531+
return CBM_PATH_INFO_UNAVAILABLE;
532+
}
533+
status = cbm_path_info_utf8(canonical, info);
534+
}
535+
return status;
536+
}
537+
525538
static bool git_has_own_dot_git(const char *root_path) {
526539
char path[CBM_SZ_4K];
527540
int written = snprintf(path, sizeof(path), "%s/.git", root_path);
528541
if (written <= 0 || (size_t)written >= sizeof(path)) {
529542
return false;
530543
}
531-
struct stat st;
532-
return stat(path, &st) == 0 && (S_ISDIR(st.st_mode) || S_ISREG(st.st_mode));
544+
cbm_path_info_t info;
545+
return watcher_path_info(path, &info) == CBM_PATH_INFO_OK &&
546+
(info.is_directory || info.is_regular);
533547
}
534548

535549
/* 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) {
9901004
if (!root_path) {
9911005
return ROOT_UNCERTAIN;
9921006
}
993-
struct stat st;
994-
if (stat(root_path, &st) == 0) {
995-
/* Exists but is no longer a directory → the root directory is gone. */
996-
return S_ISDIR(st.st_mode) ? ROOT_PRESENT : ROOT_MISSING;
1007+
cbm_path_info_t info;
1008+
int status = watcher_path_info(root_path, &info);
1009+
if (status == CBM_PATH_INFO_OK) {
1010+
return info.is_directory ? ROOT_PRESENT : ROOT_MISSING;
9971011
}
1012+
#ifdef _WIN32
1013+
*out_errno = status == CBM_PATH_INFO_ABSENT ? ENOENT : EIO;
1014+
#else
9981015
*out_errno = errno;
999-
return cbm_watcher_root_missing_errno(errno) ? ROOT_MISSING : ROOT_UNCERTAIN;
1016+
#endif
1017+
return status == CBM_PATH_INFO_ABSENT ? ROOT_MISSING : ROOT_UNCERTAIN;
10001018
}
10011019

10021020
/* 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) {
13381356

13391357
/* Init baseline for a project: check if git, get HEAD, count files */
13401358
static bool init_baseline(cbm_watcher_t *w, project_state_t *s) {
1341-
struct stat st;
1342-
if (stat(s->root_path, &st) != 0) {
1359+
cbm_path_info_t info;
1360+
if (watcher_path_info(s->root_path, &info) != CBM_PATH_INFO_OK || !info.is_directory) {
13431361
cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path);
13441362
s->baseline_done = true;
13451363
s->is_git = false;

‎tests/test_cypher.c‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,6 +1917,32 @@ TEST(cypher_exists_has_outgoing_calls) {
19171917
ASSERT_EQ(rc, 0);
19181918
ASSERT_EQ(r.row_count, 2);
19191919
cbm_cypher_result_free(&r);
1920+
1921+
rc = cbm_cypher_execute(s,
1922+
"MATCH (a)-[:CALLS]->(b) WHERE b.name <> \"SubmitOrder\" "
1923+
"RETURN a.name, b.name",
1924+
"test", 0, &r);
1925+
ASSERT_EQ(rc, 0);
1926+
ASSERT_EQ(r.row_count, 2);
1927+
cbm_cypher_result_free(&r);
1928+
1929+
rc = cbm_cypher_execute(s,
1930+
"MATCH (a)-[:CALLS]->(b) WHERE b.name = \"SubmitOrder\" OR "
1931+
"NOT b.name = \"SubmitOrder\" RETURN count(*)",
1932+
"test", 0, &r);
1933+
ASSERT_EQ(rc, 0);
1934+
ASSERT_EQ(r.row_count, 1);
1935+
ASSERT_STR_EQ(r.rows[0][0], "3");
1936+
cbm_cypher_result_free(&r);
1937+
1938+
rc = cbm_cypher_execute(s,
1939+
"MATCH (a)-[:CALLS]->(b) WITH a, b WHERE NOT b.name = "
1940+
"\"SubmitOrder\" RETURN a.name, b.name",
1941+
"test", 0, &r);
1942+
ASSERT_EQ(rc, 0);
1943+
ASSERT_EQ(r.row_count, 2);
1944+
cbm_cypher_result_free(&r);
1945+
19201946
cbm_store_close(s);
19211947
PASS();
19221948
}
@@ -3378,6 +3404,86 @@ TEST(cypher_exec_where_mixed_alias_xor) {
33783404
PASS();
33793405
}
33803406

3407+
TEST(cypher_exec_where_not_target_node) {
3408+
cbm_store_t *s = setup_cypher_store();
3409+
cbm_cypher_result_t r = {0};
3410+
int rc = cbm_cypher_execute(s,
3411+
"MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = \"SubmitOrder\" "
3412+
"RETURN a.name, b.name",
3413+
"test", 0, &r);
3414+
ASSERT_EQ(rc, 0);
3415+
ASSERT_EQ(r.row_count, 2);
3416+
cbm_cypher_result_free(&r);
3417+
cbm_store_close(s);
3418+
PASS();
3419+
}
3420+
3421+
TEST(cypher_exec_where_not_target_relationship) {
3422+
cbm_store_t *s = setup_cypher_multi_edge_store();
3423+
cbm_cypher_result_t r = {0};
3424+
int rc = cbm_cypher_execute(s,
3425+
"MATCH (a)-[r:HTTP_CALLS]->(b) WHERE NOT r.url_path = "
3426+
"\"/health\" RETURN r.url_path",
3427+
"testproj", 0, &r);
3428+
ASSERT_EQ(rc, 0);
3429+
ASSERT_EQ(r.row_count, 1);
3430+
ASSERT_STR_EQ(r.rows[0][0], "/api/orders");
3431+
cbm_cypher_result_free(&r);
3432+
cbm_store_close(s);
3433+
PASS();
3434+
}
3435+
3436+
TEST(cypher_exec_where_not_source_node) {
3437+
cbm_store_t *s = setup_cypher_store();
3438+
cbm_cypher_result_t r = {0};
3439+
int rc = cbm_cypher_execute(s,
3440+
"MATCH (a)-[:CALLS]->(b) WHERE NOT a.name = "
3441+
"\"HandleOrder\" RETURN a.name, b.name",
3442+
"test", 0, &r);
3443+
ASSERT_EQ(rc, 0);
3444+
ASSERT_EQ(r.row_count, 1);
3445+
ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder");
3446+
cbm_cypher_result_free(&r);
3447+
cbm_store_close(s);
3448+
PASS();
3449+
}
3450+
3451+
TEST(cypher_exec_where_not_target_node_count) {
3452+
cbm_store_t *s = setup_cypher_store();
3453+
cbm_cypher_result_t r = {0};
3454+
int rc = cbm_cypher_execute(s,
3455+
"MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = "
3456+
"\"SubmitOrder\" RETURN count(*)",
3457+
"test", 0, &r);
3458+
ASSERT_EQ(rc, 0);
3459+
ASSERT_EQ(r.row_count, 1);
3460+
ASSERT_STR_EQ(r.rows[0][0], "2");
3461+
cbm_cypher_result_free(&r);
3462+
cbm_store_close(s);
3463+
PASS();
3464+
}
3465+
3466+
/* The early WHERE pass must wait for every alias a condition reads. A function
3467+
* condition is filed under its first argument's alias, so in
3468+
* coalesce(a.no_such_prop, b.name) the target b hid behind the bound a: the
3469+
* seed row read coalesce(NULL, <unbound b>) as a definite mismatch and pruned
3470+
* every row before CALLS expansion, while the same predicate after WITH was
3471+
* right. */
3472+
TEST(cypher_exec_where_func_arg_on_unbound_target) {
3473+
cbm_store_t *s = setup_cypher_store();
3474+
cbm_cypher_result_t r = {0};
3475+
int rc = cbm_cypher_execute(s,
3476+
"MATCH (a)-[:CALLS]->(b) WHERE coalesce(a.no_such_prop, b.name) = "
3477+
"\"SubmitOrder\" RETURN a.name",
3478+
"test", 0, &r);
3479+
ASSERT_EQ(rc, 0);
3480+
ASSERT_EQ(r.row_count, 1);
3481+
ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder");
3482+
cbm_cypher_result_free(&r);
3483+
cbm_store_close(s);
3484+
PASS();
3485+
}
3486+
33813487
TEST(cypher_exec_where_in) {
33823488
cbm_store_t *s = setup_cypher_store();
33833489
cbm_cypher_result_t r = {0};
@@ -6243,6 +6349,11 @@ SUITE(cypher) {
62436349
RUN_TEST(cypher_exec_where_mixed_alias_and);
62446350
RUN_TEST(cypher_exec_where_mixed_alias_or);
62456351
RUN_TEST(cypher_exec_where_mixed_alias_xor);
6352+
RUN_TEST(cypher_exec_where_not_target_node);
6353+
RUN_TEST(cypher_exec_where_not_target_relationship);
6354+
RUN_TEST(cypher_exec_where_not_source_node);
6355+
RUN_TEST(cypher_exec_where_not_target_node_count);
6356+
RUN_TEST(cypher_exec_where_func_arg_on_unbound_target);
62466357
RUN_TEST(cypher_exec_where_in);
62476358
RUN_TEST(cypher_exec_where_not_in);
62486359
RUN_TEST(cypher_exec_where_is_null);

‎tests/test_extraction.c‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1777,6 +1777,33 @@ TEST(haskell_function) {
17771777
PASS();
17781778
}
17791779

1780+
/* #2440: an infix-form definition (`a <+> b = ...`, ``x `plus` y = ...``) has
1781+
* no `name` field; its first named child is an `infix` node whose first child
1782+
* is the LEFT OPERAND, so the def was named `a` / `x`. It must be named after
1783+
* the operator, consistent with the prefix form `(<+>) a b = ...`. */
1784+
TEST(haskell_infix_definition_name) {
1785+
CBMFileResult *r = extract("module M where\n"
1786+
"\n"
1787+
"(<+>) :: Int -> Int -> Int\n"
1788+
"a <+> b = a + b\n"
1789+
"\n"
1790+
"plus :: Int -> Int -> Int\n"
1791+
"x `plus` y = x + y\n"
1792+
"\n"
1793+
"f :: Int\n"
1794+
"f = 1 <+> 2\n",
1795+
CBM_LANG_HASKELL, "t", "M.hs");
1796+
ASSERT_NOT_NULL(r);
1797+
ASSERT_FALSE(r->has_error);
1798+
ASSERT(has_def(r, "Function", "f"));
1799+
ASSERT(has_def(r, "Function", "plus"));
1800+
ASSERT(has_def(r, "Function", "(<+>)"));
1801+
ASSERT_FALSE(has_def(r, "Function", "a"));
1802+
ASSERT_FALSE(has_def(r, "Function", "x"));
1803+
cbm_free_result(r);
1804+
PASS();
1805+
}
1806+
17801807
/* --- OCaml --- */
17811808
TEST(ocaml_function) {
17821809
CBMFileResult *r =
@@ -13177,6 +13204,7 @@ SUITE(extraction) {
1317713204
RUN_TEST(elixir_function);
1317813205
RUN_TEST(elixir_call_string_argument);
1317913206
RUN_TEST(haskell_function);
13207+
RUN_TEST(haskell_infix_definition_name);
1318013208
RUN_TEST(ocaml_function);
1318113209
RUN_TEST(erlang_function);
1318213210

‎tests/test_language_count_contract.sh‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,12 @@ if [[ "$manifest_count" != "$expected" ]]; then
4646
fi
4747

4848
# --- 3. Every published surface must claim exactly that number. ---
49+
# The #2729 messaging refresh dropped the count from docs/index.html,
50+
# docs/llms.txt, pkg/npm/README.md, scripts/package-release.sh and server.json;
51+
# section 4 below still fails if any of them states a count again unregistered.
4952
SURFACES=(
5053
README.md
51-
docs/index.html
52-
docs/llms.txt
53-
pkg/npm/README.md
5454
pkg/chocolatey/codebase-memory-mcp.nuspec
55-
scripts/package-release.sh
56-
server.json
5755
)
5856

5957
# Files that legitimately say "<N> languages" about something OTHER than the

‎tests/test_select_lanes.sh‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,31 @@ if proc.returncode != 0 or got != ["internal/cbm/grammar_go.c",
275275
"vendored/sqlite3/sqlite3.c"]:
276276
failures.append(f"codeql (c): the retrace helper must list the translation units for "
277277
f"changed vendored code and wrappers, got {got} (exit {proc.returncode})")
278+
# Native Windows Python emits CRLF even when the change list uses LF. Pin
279+
# that producer/consumer boundary on every platform, including direct wrapper
280+
# paths that must survive verbatim rather than being mapped to a family name.
281+
crlf_root = work / "crlf-selector"
282+
crlf_scripts = crlf_root / "scripts" / "ci"
283+
crlf_scripts.mkdir(parents=True)
284+
(crlf_scripts / retrace.name).write_bytes(retrace.read_bytes())
285+
expected_retrace = ["internal/cbm/grammar_go.c", "internal/cbm/grammar_json.c",
286+
"internal/cbm/ts_runtime.c", "vendored/sqlite3/sqlite3.c"]
287+
for path in expected_retrace:
288+
fixture = crlf_root / path
289+
fixture.parent.mkdir(parents=True, exist_ok=True)
290+
fixture.touch()
291+
(crlf_scripts / "select-lanes.sh").write_bytes(
292+
b"#!/usr/bin/env bash\nprintf '%s\\r\\n' "
293+
b"internal/cbm/vendored/grammars/go/parser.c internal/cbm/grammar_json.c "
294+
b"internal/cbm/ts_runtime.c vendored/sqlite3/sqlite3.c\n")
295+
proc = subprocess.run([bash, str(crlf_scripts / retrace.name),
296+
"--changed", str(changed), str(listed)],
297+
capture_output=True, text=True, env=env)
298+
got = listed.read_bytes() if listed.exists() else None
299+
if proc.returncode != 0 or got != ("\n".join(expected_retrace) + "\n").encode("utf-8"):
300+
failures.append(f"codeql CRLF: the retrace helper must preserve every changed "
301+
f"wrapper and write LF entrypoints, got {got!r} (exit {proc.returncode})")
302+
278303
for label, args in (("an unreadable change list", ["--changed", str(work / "missing.txt")]),
279304
("no pull request", [])):
280305
listed.write_text("stale\n", encoding="utf-8")

0 commit comments

Comments
 (0)