Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 61 additions & 14 deletions src/pipeline/path_alias.c
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
48 changes: 48 additions & 0 deletions tests/test_path_alias.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
Loading