diff --git a/src/pipeline/path_alias.c b/src/pipeline/path_alias.c index 79e26b08..caf2d58b 100644 --- a/src/pipeline/path_alias.c +++ b/src/pipeline/path_alias.c @@ -63,27 +63,74 @@ static char *strip_resolved_ext(char *path) { return path; } -/* If target starts with "./" and dir_prefix is non-empty, prepend dir_prefix. - * Returns heap-allocated repo-relative target. */ +/* Join dir_prefix with target, collapsing "." and ".." segments so aliases + * that climb out of their tsconfig's directory (the common monorepo + * pattern: a tsconfig at apps/web/tsconfig.json pointing an alias at a + * wildcard target like "../../packages/shared/src/" + wildcard) resolve + * to a real repo-relative path. Naive concatenation left literal ".." + * components in the target, which never match a module's FQN since + * cbm_pipeline_fqn_module tokenizes on '/' without collapsing them + * (#730). A trailing '/' on target (the usual case right before a + * wildcard) is preserved so the caller's later wildcard-substring + * concat still lines up. Returns heap-allocated + * repo-relative target. */ static char *resolve_target_relative(const char *dir_prefix, const char *target) { if (!target) { return NULL; } - const char *t = target; - if (t[0] == '.' && t[1] == '/') { - t += 2; + size_t dp_len = (dir_prefix && dir_prefix[0] != '\0') ? strlen(dir_prefix) : 0; + size_t t_len = strlen(target); + char *buf = malloc(dp_len + t_len + 2); + if (!buf) { + return NULL; } - if (!dir_prefix || dir_prefix[0] == '\0') { - return strdup(t); + buf[0] = '\0'; + if (dp_len > 0) { + memcpy(buf, dir_prefix, dp_len); + buf[dp_len] = '\0'; } - size_t dp_len = strlen(dir_prefix); - size_t t_len = strlen(t); - char *result = malloc(dp_len + 1 + t_len + 1); - if (!result) { - return NULL; + + bool trailing_slash = t_len > 0 && target[t_len - 1] == '/'; + + const char *p = target; + while (*p) { + while (*p == '/') { + p++; + } + if (!*p) { + break; + } + const char *seg_start = p; + while (*p && *p != '/') { + p++; + } + size_t seg_len = (size_t)(p - seg_start); + if (seg_len == 1 && seg_start[0] == '.') { + continue; + } + if (seg_len == 2 && seg_start[0] == '.' && seg_start[1] == '.') { + char *last = strrchr(buf, '/'); + if (last) { + *last = '\0'; + } else { + buf[0] = '\0'; + } + continue; + } + size_t cur = strlen(buf); + if (cur > 0) { + buf[cur++] = '/'; + } + memcpy(buf + cur, seg_start, seg_len); + buf[cur + seg_len] = '\0'; + } + + if (trailing_slash) { + size_t cur = strlen(buf); + buf[cur] = '/'; + buf[cur + 1] = '\0'; } - snprintf(result, dp_len + 1 + t_len + 1, "%s/%s", dir_prefix, t); - return result; + return buf; } /* qsort comparator: alias entries by alias_prefix length, descending. */ diff --git a/tests/test_path_alias.c b/tests/test_path_alias.c index 820c487f..a9a447a8 100644 --- a/tests/test_path_alias.c +++ b/tests/test_path_alias.c @@ -289,6 +289,53 @@ TEST(path_alias_loader_monorepo) { PASS(); } +/* ── Monorepo alias climbing out of its tsconfig's directory (#730) ── */ + +TEST(path_alias_loader_monorepo_dotdot_climb) { + char tmpl[256]; + snprintf(tmpl, sizeof(tmpl), "/tmp/cbm_palias_climb_XXXXXX"); + char *root = cbm_mkdtemp(tmpl); + ASSERT_NOT_NULL(root); + + char sub[512]; + snprintf(sub, sizeof(sub), "%s/apps", root); + cbm_mkdir(sub); + snprintf(sub, sizeof(sub), "%s/apps/web", root); + cbm_mkdir(sub); + + char path[512]; + snprintf(path, sizeof(path), "%s/apps/web/tsconfig.json", root); + ASSERT_EQ(write_file(path, + "{\n \"compilerOptions\": {\n \"paths\": {\n" + " \"@shared/*\": [\"../../packages/shared/src/*\"]\n" + " }\n }\n}\n"), + 0); + + cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root); + ASSERT_NOT_NULL(coll); + + const cbm_path_alias_map_t *m = + cbm_path_alias_find_for_file(coll, "apps/web/src/feature/x.ts"); + ASSERT_NOT_NULL(m); + char *r = cbm_path_alias_resolve(m, "@shared/utils"); + ASSERT_NOT_NULL(r); + /* "../.." from apps/web climbs to repo root, then descends into + * packages/shared/src — not the literal (unmatchable) "apps/web/../../..." */ + ASSERT_STR_EQ(r, "packages/shared/src/utils"); + free(r); + + cbm_path_alias_collection_free(coll); + + snprintf(path, sizeof(path), "%s/apps/web/tsconfig.json", root); + unlink(path); + snprintf(path, sizeof(path), "%s/apps/web", root); + rmdir(path); + snprintf(path, sizeof(path), "%s/apps", root); + rmdir(path); + rmdir(root); + PASS(); +} + /* ── Loader returns NULL when no configs found ─────────────────── */ TEST(path_alias_loader_no_configs) { @@ -315,5 +362,6 @@ void suite_path_alias(void) { RUN_TEST(path_alias_null_safety); RUN_TEST(path_alias_find_for_file_nearest_ancestor); RUN_TEST(path_alias_loader_monorepo); + RUN_TEST(path_alias_loader_monorepo_dotdot_climb); RUN_TEST(path_alias_loader_no_configs); }