From 85532247fa96b529e5766a65038008320e85fe48 Mon Sep 17 00:00:00 2001 From: Ulises Millan Guerrero Date: Fri, 21 Aug 2026 13:50:35 -0600 Subject: [PATCH 01/15] fix(workspace): treat '/' and '\' as the same separator in root matching The Windows sensitive-root escape hatch broke on separator spelling: the grant is recorded from cbm_canonical_path (backslashes) while the indexer hands the candidate normalized to '/'. ws_is_ancestor_or_equal compared bytes, so an approved 'Program Files' root never matched the candidate and --approve-sensitive could not lift the refusal. Compare component boundaries with separator equivalence, consistent with ws_is_sep used everywhere else in this module. The containment rule (a separator byte is never part of a component name) is unchanged. Fixes #1718 Signed-off-by: Ulises Millan Guerrero --- src/foundation/workspace.c | 20 +++++++-- tests/test_workspace.c | 91 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/src/foundation/workspace.c b/src/foundation/workspace.c index a85e17822..8c6b34b8d 100644 --- a/src/foundation/workspace.c +++ b/src/foundation/workspace.c @@ -212,7 +212,16 @@ static bool ws_any_component_matches(const char *path, const char *const *names, } /* True when b is a or lives under a. Compares on a separator boundary so - * "/a/bc" is not treated as living under "/a/b". */ + * "/a/bc" is not treated as living under "/a/b". + * + * '/' and '\' are equivalent here, as they are everywhere else in this module + * (ws_is_sep). The Windows sensitive-grant escape hatch (#1718) records the + * grant from cbm_canonical_path — backslashes — while the indexer candidate + * arrives normalized to '/'. A byte-exact comparison made the grant never + * match the candidate on Windows, so --approve-sensitive could not lift a + * "Program Files" refusal. Comparing on the separator-equivalence boundary + * keeps the containment semantics (a separator byte is never "part of a + * component name") while erasing the platform spelling difference. */ static bool ws_is_ancestor_or_equal(const char *a, const char *b) { if (!a || !b || !a[0] || !b[0]) { return false; @@ -221,8 +230,13 @@ static bool ws_is_ancestor_or_equal(const char *a, const char *b) { while (la > 1 && ws_is_sep(a[la - 1])) { la--; } - if (strncmp(a, b, la) != 0) { - return false; + for (size_t i = 0; i < la; i++) { + if (a[i] == b[i]) { + continue; + } + if (!(ws_is_sep(a[i]) && ws_is_sep(b[i]))) { + return false; + } } return b[la] == '\0' || ws_is_sep(b[la]); } diff --git a/tests/test_workspace.c b/tests/test_workspace.c index 736b4477f..89af1d215 100644 --- a/tests/test_workspace.c +++ b/tests/test_workspace.c @@ -261,6 +261,96 @@ TEST(ws_manifest_approval_refuses_overbroad_requests) { PASS(); } +/* #1718: the sensitive-root escape hatch broke on Windows separator spelling. + * The grant is recorded from cbm_canonical_path — backslashes — while the + * indexer hands the candidate already normalized to '/'. A byte-exact + * comparison made the grant never match the candidate, so an approved + * "Program Files" root stayed refused. '/' and '\' are equivalent everywhere + * else in this module (ws_is_sep); the boundary comparison must treat them + * equally. The classify_root home rule uses the same ws_paths_equal, so a + * mixed-separator home spelling is the string-level regression check; the + * FS-backed grant round-trip lives in the _WIN32 half, where the real + * canonicalization exercises it. */ +TEST(ws_approve_sensitive_matches_across_separator_styles) { + /* canonical spelled with '\' vs home spelled with '/' — must classify as + * the same directory. Pre-fix: strncmp mismatch at the separator byte, so + * the home rule never matched and an ordinary path under "C:\Users" fell + * through to CBM_WS_ALLOW. */ + ASSERT_EQ(cbm_workspace_classify_root("C:\\Users\\dev", "C:/Users/dev", CACHE), + CBM_WS_DENY_SENSITIVE); + ASSERT_EQ(cbm_workspace_classify_root("C:/Users/dev", "C:\\Users\\dev", CACHE), + CBM_WS_DENY_SENSITIVE); + /* A sibling that differs after the same separator boundary is still a + * different directory: "/a/bc" must never look like "/a/b". */ + ASSERT_EQ(cbm_workspace_classify_root("C:\\Users\\devx", "C:/Users/dev", CACHE), CBM_WS_ALLOW); +#ifdef _WIN32 + /* Full grant round-trip on real filesystem paths: the grant stored with + * '\' must lift the exact candidate the indexer passes with '/'. The + * sensitive class is exercised by a credential-named directory (".ssh"), + * which is DENY_SENSITIVE at any depth — equivalent to "Program Files" + * for the refusal path under test. */ + char *cache = th_mktempdir("cbm_ws_sw"); + ASSERT(cache != NULL); + char dir[1024]; + snprintf(dir, sizeof(dir), "%s/.ssh/cfg", cache); + ASSERT_TRUE(cbm_mkdir_p(dir, 0700)); + char path[1024]; + snprintf(path, sizeof(path), "%s/allowed_roots", cache); + + char grant[2048]; + snprintf(grant, sizeof(grant), "!%s\\.ssh\\cfg\n", cache); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + (void)fprintf(f, "%s", grant); + (void)fclose(f); + + char candidate[2048]; + snprintf(candidate, sizeof(candidate), "%s/.ssh/cfg", cache); + /* Convert to '/' spelling, exactly as cbm_normalize_path_sep does for the + * indexer's candidate. */ + char *p; + for (p = candidate; *p; p++) { + if (*p == '\\') { + *p = '/'; + } + } + char err[1024]; + err[0] = '\0'; + ASSERT_TRUE(cbm_workspace_root_allowed(candidate, NULL, cache, NULL, err, sizeof(err))); + + /* An unapproved sibling under the same sensitive tree stays refused. It + * lies outside every granted root, so the refusal is the boundary wording + * (kept verbatim by contract), not the sensitive hint. */ + char sibling[2048]; + snprintf(sibling, sizeof(sibling), "%s/.ssh/keys", cache); + for (p = sibling; *p; p++) { + if (*p == '\\') { + *p = '/'; + } + } + err[0] = '\0'; + ASSERT_FALSE(cbm_workspace_root_allowed(sibling, NULL, cache, NULL, err, sizeof(err))); + ASSERT_TRUE(strstr(err, "outside the allowed root") != NULL); + + /* A strict descendant INSIDE the approved sensitive grant is contained + * (boundary passes) but not the exact grant, so the sensitive refusal + * applies and the hint names the flag. */ + char descendant[2048]; + snprintf(descendant, sizeof(descendant), "%s/.ssh/cfg/sub", cache); + for (p = descendant; *p; p++) { + if (*p == '\\') { + *p = '/'; + } + } + err[0] = '\0'; + ASSERT_FALSE(cbm_workspace_root_allowed(descendant, NULL, cache, NULL, err, sizeof(err))); + ASSERT_TRUE(strstr(err, "--approve-sensitive") != NULL); + + th_cleanup(cache); +#endif + PASS(); +} + SUITE(workspace) { RUN_TEST(ws_manifest_absent_is_not_an_error); RUN_TEST(ws_manifest_parses_entries_and_skips_comments); @@ -278,4 +368,5 @@ SUITE(workspace) { RUN_TEST(ws_posix_matching_is_case_sensitive); RUN_TEST(ws_null_context_disables_dependent_checks); RUN_TEST(ws_every_verdict_has_a_reason); + RUN_TEST(ws_approve_sensitive_matches_across_separator_styles); } From c1d05f15171be639a202874fab929a8cb43644de Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Sun, 4 Oct 2026 00:15:06 +0800 Subject: [PATCH 02/15] test(parse): cover the reported TSX ampersand fragment Signed-off-by: lorenzozanee --- tests/test_parse_coverage.c | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_parse_coverage.c b/tests/test_parse_coverage.c index 8471f2121..37d1bf5e3 100644 --- a/tests/test_parse_coverage.c +++ b/tests/test_parse_coverage.c @@ -1453,6 +1453,7 @@ static const char *const JSX_AMP_BODIES[] = { "

x

", /* '&' right before the quote */ "

Tom &Jerry

", /* '&' + letter in JSX text */ "

Tom & Jerry

", /* '&' + space in JSX text */ + "Role & Dept", /* #2481 report reproduction */ "

x &1 y

", /* '&' + digit in JSX text */ /* controls: references keep parsing; '& ' / '&1' in attributes */ "

A&B & C

", From 6bc7ade18cd9fe95e4395f843090fb5e61e81538 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:37:54 -0700 Subject: [PATCH 03/15] fix(mcp): log the server/discover connect probe as info, not an error That call now passes false for the error argument when the method is server/discover and true for every other unknown method, so the probe is logged as level=info with status=ok while other misses stay level=warn and status=error. The JSON-RPC response is still -32601 Method not found. The new test server_handle_discover_probe_logs_info checks those log fields, checks that unknown/method still logs a warning, checks that an initialize request for 2026-07-28 still negotiates 2025-11-25, and checks that resources/list still returns an empty resource list. Fixes #2433 Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- src/mcp/mcp.c | 4 +++- tests/test_mcp.c | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 837a858a4..5edcc59aa 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -18681,7 +18681,9 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { cbm_clock_gettime(CLOCK_MONOTONIC, &t1); long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) + ((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US); - cbm_log_mcp_request(req.method, NULL, true, dur_us); + /* server/discover is the modern connect probe. -32601 stays so the + * client falls back to initialize; that miss is not an error (#2433). */ + cbm_log_mcp_request(req.method, NULL, strcmp(req.method, "server/discover") != 0, dur_us); cbm_mcp_server_request_scope_end(srv); cbm_jsonrpc_request_free(&req); return err; diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 618c4efc7..c7c91df0a 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -2355,6 +2355,59 @@ TEST(server_handle_unknown_method) { PASS(); } +/* Issue #2433: a modern client probes server/discover before initialize. + * The -32601 stays so it falls back; only that probe is logged as info. */ +TEST(server_handle_discover_probe_logs_info) { + mcp_log_buf[0] = '\0'; + CBMLogLevel prev_level = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_DEBUG); + cbm_log_set_format(CBM_LOG_FORMAT_TEXT); + cbm_log_set_sink_ex(mcp_capture_log, CBM_LOG_SINK_REPLACE); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"code\":-32601")); + ASSERT_NOT_NULL(strstr(resp, "Method not found")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "method=server/discover")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "level=info")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "status=ok")); + ASSERT_NULL(strstr(mcp_log_buf, "level=warn")); + ASSERT_NULL(strstr(mcp_log_buf, "status=error")); + free(resp); + + mcp_log_buf[0] = '\0'; + resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"unknown/method\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "-32601")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "method=unknown/method")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "level=warn")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "status=error")); + free(resp); + + resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2026-07-28\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "2025-11-25")); + ASSERT_NULL(strstr(resp, "2026-07-28")); + free(resp); + + resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"resources/list\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"resources\":[]")); + ASSERT_NULL(strstr(resp, "-32601")); + free(resp); + + cbm_mcp_server_free(srv); + cbm_log_set_sink(NULL); + cbm_log_set_level(prev_level); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * TOOL HANDLERS (via server_handle) * ══════════════════════════════════════════════════════════════════ */ @@ -22351,6 +22404,7 @@ SUITE(mcp) { RUN_TEST(server_handle_prompts_get_validates_arguments); RUN_TEST(server_handle_logs_request_without_params); RUN_TEST(server_handle_unknown_method); + RUN_TEST(server_handle_discover_probe_logs_info); /* Server handle — edge cases */ RUN_TEST(server_handle_invalid_json); From 5c52a004311aa2c5adfa2e179721761f9b7419e0 Mon Sep 17 00:00:00 2001 From: Totopo27 <35828647+Totopo27@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:33:34 -0600 Subject: [PATCH 04/15] fix(discover): map jinja and jinja2 in userconfig language table Signed-off-by: Totopo27 <35828647+Totopo27@users.noreply.github.com> --- src/discover/userconfig.c | 2 ++ tests/test_userconfig.c | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/discover/userconfig.c b/src/discover/userconfig.c index 8f906ef07..8671d08fd 100644 --- a/src/discover/userconfig.c +++ b/src/discover/userconfig.c @@ -164,6 +164,8 @@ static const lang_name_entry_t LANG_NAME_TABLE[] = { {"form", CBM_LANG_FORM}, {"magma", CBM_LANG_MAGMA}, {"wolfram", CBM_LANG_WOLFRAM}, + {"jinja2", CBM_LANG_JINJA2}, + {"jinja", CBM_LANG_JINJA2}, }; #define LANG_NAME_TABLE_SIZE (sizeof(LANG_NAME_TABLE) / sizeof(LANG_NAME_TABLE[0])) diff --git a/tests/test_userconfig.c b/tests/test_userconfig.c index a9c3b4a5e..10eacec1b 100644 --- a/tests/test_userconfig.c +++ b/tests/test_userconfig.c @@ -39,7 +39,7 @@ TEST(userconfig_project_basic) { char proj[512]; snprintf(proj, sizeof(proj), "%s/.codebase-memory.json", dir); ASSERT_EQ( - write_json(proj, "{\"extra_extensions\":{\".blade.php\":\"php\",\".mjs\":\"javascript\"}}"), + write_json(proj, "{\"extra_extensions\":{\".blade.php\":\"php\",\".mjs\":\"javascript\",\".html\":\"jinja2\"}}"), 0); cbm_userconfig_t *cfg = cbm_userconfig_load(dir); @@ -47,6 +47,7 @@ TEST(userconfig_project_basic) { ASSERT_EQ(cbm_userconfig_lookup(cfg, ".blade.php"), CBM_LANG_PHP); ASSERT_EQ(cbm_userconfig_lookup(cfg, ".mjs"), CBM_LANG_JAVASCRIPT); + ASSERT_EQ(cbm_userconfig_lookup(cfg, ".html"), CBM_LANG_JINJA2); ASSERT_EQ(cbm_userconfig_lookup(cfg, ".go"), CBM_LANG_COUNT); /* not in user config */ cbm_userconfig_free(cfg); From ef551460ce5fe432eca00458d338c24c910284d4 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Sun, 11 Oct 2026 00:01:23 +0800 Subject: [PATCH 05/15] fix(ts-lsp): resolve JSX calls from top-level arrow components Signed-off-by: lorenzozanee --- internal/cbm/lsp/ts_lsp.c | 40 ++++++++++++++++++- tests/test_ts_lsp.c | 84 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index c25a58bbe..e18ee0c5a 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -3760,6 +3760,31 @@ static void process_function_body(TSLSPContext *ctx, TSNode func_node, const cha ctx->current_scope = saved_scope; } +static void process_top_level_variable_arrows(TSLSPContext *ctx, TSNode declaration) { + if (!ctx || ts_node_is_null(declaration) || !ctx->module_qn) + return; + + uint32_t count = ts_node_named_child_count(declaration); + for (uint32_t i = 0; i < count; i++) { + TSNode declarator = ts_node_named_child(declaration, i); + if (!node_kind_is(declarator, "variable_declarator")) + continue; + + TSNode name = ts_node_child_by_field_name(declarator, "name", TS_LSP_FIELD_LEN("name")); + TSNode value = + ts_node_child_by_field_name(declarator, "value", TS_LSP_FIELD_LEN("value")); + if (!node_kind_is(name, "identifier") || !node_kind_is(value, "arrow_function")) + continue; + + char *function_name = node_text(ctx, name); + if (!function_name) + continue; + const char *function_qn = + cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, function_name); + process_function_body(ctx, value, function_qn, NULL); + } +} + void ts_lsp_process_file(TSLSPContext *ctx, TSNode root) { if (!ctx || ts_node_is_null(root)) return; @@ -3777,6 +3802,14 @@ void ts_lsp_process_file(TSLSPContext *ctx, TSNode root) { const char *kind = ts_node_type(child); if (strcmp(kind, "lexical_declaration") == 0 || strcmp(kind, "variable_declaration") == 0) { ts_process_statement(ctx, child); + } else if (strcmp(kind, "export_statement") == 0) { + uint32_t count = ts_node_named_child_count(child); + for (uint32_t j = 0; j < count; j++) { + TSNode inner = ts_node_named_child(child, j); + if (node_kind_is(inner, "lexical_declaration") || + node_kind_is(inner, "variable_declaration")) + ts_process_statement(ctx, inner); + } } } @@ -3785,7 +3818,9 @@ void ts_lsp_process_file(TSLSPContext *ctx, TSNode root) { TSNode child = kids[i]; const char *kind = ts_node_type(child); - if (strcmp(kind, "function_declaration") == 0) { + if (strcmp(kind, "lexical_declaration") == 0 || strcmp(kind, "variable_declaration") == 0) { + process_top_level_variable_arrows(ctx, child); + } else if (strcmp(kind, "function_declaration") == 0) { TSNode name = ts_node_child_by_field_name(child, "name", TS_LSP_FIELD_LEN("name")); if (ts_node_is_null(name) || !ctx->module_qn) continue; @@ -3824,6 +3859,9 @@ void ts_lsp_process_file(TSLSPContext *ctx, TSNode root) { continue; const char *fqn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, fn); process_function_body(ctx, inner, fqn, NULL); + } else if (strcmp(ik, "lexical_declaration") == 0 || + strcmp(ik, "variable_declaration") == 0) { + process_top_level_variable_arrows(ctx, inner); } else if (strcmp(ik, "class_declaration") == 0 || strcmp(ik, "internal_module") == 0) { process_node(ctx, inner); diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index 12b4ef2bd..ea35264ec 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -1157,6 +1157,89 @@ TEST(tslsp_jsx_component_resolutions_join_exact_tag_sites) { PASS(); } +TEST(tslsp_jsx_top_level_async_arrow_callers_keep_exact_identity) { + const char *source = "function Card(): JSX.Element { return ; }\n" + "const Direct = async () => ;\n" + "export const Exported = async () => ;\n" + "function Declared(): JSX.Element { return ; }\n"; + const char *const declarations[] = {"const Direct", "export const Exported", + "function Declared"}; + const char *const callers[] = {"test.main.Direct", "test.main.Exported", "test.main.Declared"}; + CBMFileResult *r = extract_tsx(source); + const bool result_created = r != NULL; + bool exact_carrier[3] = {false}; + bool exact_resolution[3] = {false}; + bool resolved_invocation[3] = {false}; + bool resolved_card_callee[3] = {false}; + bool resolved_jsx_strategy[3] = {false}; + int carrier_count[3] = {0}; + int failed_cases = result_created ? 0 : 1; + + if (!result_created) + printf(" JSX caller diagnostic: extraction failed\n"); + if (r) { + for (int i = 0; i < 3; i++) { + const char *declaration = strstr(source, declarations[i]); + const char *site = declaration ? strstr(declaration, "") : NULL; + if (!site) { + printf(" JSX caller diagnostic: source site missing for %s\n", callers[i]); + failed_cases++; + continue; + } + const uint32_t start = (uint32_t)(site - source); + const uint32_t end = start + (uint32_t)strlen(""); + const CBMCall *carrier = NULL; + for (int j = 0; j < r->calls.count; j++) { + const CBMCall *call = &r->calls.items[j]; + if (call->callee_name && strcmp(call->callee_name, "Card") == 0 && + call->site_start_byte == start && call->site_end_byte == end) { + carrier = call; + carrier_count[i]++; + } + } + + const CBMResolvedCall *resolved = carrier ? cbm_pipeline_find_lsp_resolution( + &r->resolved_calls, carrier, false) + : NULL; + exact_carrier[i] = carrier && carrier->enclosing_func_qn && + strcmp(carrier->enclosing_func_qn, callers[i]) == 0 && + carrier->site_start_byte == start && carrier->site_end_byte == end && + carrier->requires_lsp_resolution; + exact_resolution[i] = resolved && resolved->caller_qn && + strcmp(resolved->caller_qn, callers[i]) == 0 && + resolved->site_start_byte == start && resolved->site_end_byte == end; + resolved_invocation[i] = resolved && resolved->kind == CBM_RESOLVED_INVOCATION; + if (resolved && resolved->callee_qn) { + const size_t callee_len = strlen(resolved->callee_qn); + resolved_card_callee[i] = callee_len >= strlen(".Card") && + strcmp(resolved->callee_qn + callee_len - strlen(".Card"), + ".Card") == 0; + } + resolved_jsx_strategy[i] = + resolved && resolved->strategy && strcmp(resolved->strategy, "lsp_ts_jsx") == 0; + if (carrier_count[i] != 1 || !exact_carrier[i] || !exact_resolution[i] || + !resolved_invocation[i] || !resolved_card_callee[i] || !resolved_jsx_strategy[i]) { + printf(" JSX caller diagnostic: expected=%s span=%u:%u carriers=%d caller=%s " + "requires_lsp=%d resolution=%s resolution_span=%u:%u kind=%d callee=%s " + "strategy=%s\n", + callers[i], start, end, carrier_count[i], + carrier && carrier->enclosing_func_qn ? carrier->enclosing_func_qn : "(null)", + carrier ? carrier->requires_lsp_resolution : 0, + resolved && resolved->caller_qn ? resolved->caller_qn : "(null)", + resolved ? resolved->site_start_byte : 0, resolved ? resolved->site_end_byte : 0, + resolved ? resolved->kind : -1, + resolved && resolved->callee_qn ? resolved->callee_qn : "(null)", + resolved && resolved->strategy ? resolved->strategy : "(null)"); + failed_cases++; + } + } + } + + cbm_free_result(r); + ASSERT_EQ(failed_cases, 0); + PASS(); +} + /* A callable value named like a module component owns the JSX tag. The LSP * pass must not confidently redirect it to the module function, and the * carrier must require semantic resolution so graph construction cannot do @@ -4867,6 +4950,7 @@ SUITE(ts_lsp) { RUN_TEST(tslsp_jsx_component_with_children); RUN_TEST(tslsp_jsx_import_requires_registered_component); RUN_TEST(tslsp_jsx_component_resolutions_join_exact_tag_sites); + RUN_TEST(tslsp_jsx_top_level_async_arrow_callers_keep_exact_identity); RUN_TEST(tslsp_jsx_local_tag_shadow_blocks_module_component); RUN_TEST(tslsp_jsx_intrinsic_skipped); RUN_TEST(tslsp_jsx_nested_component); From c1e2414fb085dc63fa89a8d6e9df5a44c44d4a72 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Mon, 12 Oct 2026 00:19:31 +0800 Subject: [PATCH 06/15] fix(pipeline): resolve parent-relative non-code imports Signed-off-by: lorenzozanee --- src/pipeline/fqn.c | 3 ++ src/pipeline/pass_pkgmap.c | 16 +++++++ tests/test_edge_imports.c | 98 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) diff --git a/src/pipeline/fqn.c b/src/pipeline/fqn.c index 96e959a0b..cb2c49a5b 100644 --- a/src/pipeline/fqn.c +++ b/src/pipeline/fqn.c @@ -317,6 +317,9 @@ static char *resolve_js_relative(char *buf, size_t buf_size, const char *module_ continue; } if (seg_len == FQN_DOTDOT_LEN && seg_start[0] == '.' && seg_start[FQN_SEP_LEN] == '.') { + if (!buf[0]) { + return NULL; + } path_pop_segment(buf); continue; } diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index f12dd8d4e..c934c9829 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -2305,6 +2305,22 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t } } + /* Relative non-module files can have a File QN that does not match the + * module QN above. Resolve the exact file after module and sibling lookup. */ + if (imp->module_path[0] == '.' && + (imp->module_path[1] == '/' || + (imp->module_path[1] == '.' && imp->module_path[2] == '/'))) { + char *resolved = cbm_pipeline_resolve_relative_import(source_rel, imp->module_path); + if (resolved) { + const cbm_gbuf_node_t *file = + cbm_pipeline_file_node(ctx->gbuf, ctx->project_name, resolved); + free(resolved); + if (file) { + return file; + } + } + } + /* Strategy 2: namespace map. `using App.Utils`, `import com.example.Foo`, * `use App\Utils\Helper` name a NAMESPACE (or a member of it) that the * path-based QN cannot express. Try the full module path and progressively diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 685cad0fb..fbeb90942 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -266,6 +266,102 @@ static int ei_edge_present(const EILangFile *files, int nfiles, const char *edge return got >= floor; } +typedef struct { + const char *importer; + const char *target; +} EIFileEdgeExpect; + +static int ei_file_edges_match(const EILangFile *files, int nfiles, const EIFileEdgeExpect *want, + int nwant, int expected_count) { + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, files, nfiles); + int ok = store && cbm_store_count_edges_by_type(store, lp.project, "IMPORTS") == expected_count; + if (store && !ok) { + int got = cbm_store_count_edges_by_type(store, lp.project, "IMPORTS"); + fprintf(stderr, " [IMPORTS] FAIL count=%d expected==%d\n", got, expected_count); + } + for (int i = 0; store && i < nwant; i++) { + int64_t source_id = ei_node_id_for_file_label(store, lp.project, want[i].importer, "File"); + cbm_edge_t *edges = NULL; + int edge_count = 0; + int found = 0; + if (source_id > 0 && + cbm_store_find_edges_by_source_type(store, source_id, "IMPORTS", &edges, &edge_count) == + CBM_STORE_OK) { + for (int j = 0; j < edge_count; j++) { + cbm_node_t target; + memset(&target, 0, sizeof(target)); + if (cbm_store_find_node_by_id(store, edges[j].target_id, &target) == CBM_STORE_OK) { + found = target.file_path && strcmp(target.file_path, want[i].target) == 0; + cbm_node_free_fields(&target); + } + if (found) { + break; + } + } + } + cbm_store_free_edges(edges, edge_count); + if (!found) { + fprintf(stderr, " [IMPORTS] FAIL %s -> %s\n", want[i].importer, want[i].target); + ok = 0; + } + } + ei_cleanup(&lp, store); + return ok; +} + +TEST(ei_relative_astro_css_imports_issue2558) { + static const EILangFile f[] = { + {"Badge.astro", "outside\n"}, + {"src/components/shared/Button.astro", + "---\nexport function buttonLabel(t: string): string { return t; }\n---\n