Skip to content

Commit 6a6c7a9

Browse files
authored
Merge pull request #766 from apappas1129/fix/730-tsconfig-alias-dotdot-climb
fix(pipeline): resolve ../ climbs in tsconfig path alias targets
2 parents 96db8c8 + 7037703 commit 6a6c7a9

2 files changed

Lines changed: 109 additions & 14 deletions

File tree

src/pipeline/path_alias.c

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,27 +63,74 @@ static char *strip_resolved_ext(char *path) {
6363
return path;
6464
}
6565

66-
/* If target starts with "./" and dir_prefix is non-empty, prepend dir_prefix.
67-
* Returns heap-allocated repo-relative target. */
66+
/* Join dir_prefix with target, collapsing "." and ".." segments so aliases
67+
* that climb out of their tsconfig's directory (the common monorepo
68+
* pattern: a tsconfig at apps/web/tsconfig.json pointing an alias at a
69+
* wildcard target like "../../packages/shared/src/" + wildcard) resolve
70+
* to a real repo-relative path. Naive concatenation left literal ".."
71+
* components in the target, which never match a module's FQN since
72+
* cbm_pipeline_fqn_module tokenizes on '/' without collapsing them
73+
* (#730). A trailing '/' on target (the usual case right before a
74+
* wildcard) is preserved so the caller's later wildcard-substring
75+
* concat still lines up. Returns heap-allocated
76+
* repo-relative target. */
6877
static char *resolve_target_relative(const char *dir_prefix, const char *target) {
6978
if (!target) {
7079
return NULL;
7180
}
72-
const char *t = target;
73-
if (t[0] == '.' && t[1] == '/') {
74-
t += 2;
81+
size_t dp_len = (dir_prefix && dir_prefix[0] != '\0') ? strlen(dir_prefix) : 0;
82+
size_t t_len = strlen(target);
83+
char *buf = malloc(dp_len + t_len + 2);
84+
if (!buf) {
85+
return NULL;
7586
}
76-
if (!dir_prefix || dir_prefix[0] == '\0') {
77-
return strdup(t);
87+
buf[0] = '\0';
88+
if (dp_len > 0) {
89+
memcpy(buf, dir_prefix, dp_len);
90+
buf[dp_len] = '\0';
7891
}
79-
size_t dp_len = strlen(dir_prefix);
80-
size_t t_len = strlen(t);
81-
char *result = malloc(dp_len + 1 + t_len + 1);
82-
if (!result) {
83-
return NULL;
92+
93+
bool trailing_slash = t_len > 0 && target[t_len - 1] == '/';
94+
95+
const char *p = target;
96+
while (*p) {
97+
while (*p == '/') {
98+
p++;
99+
}
100+
if (!*p) {
101+
break;
102+
}
103+
const char *seg_start = p;
104+
while (*p && *p != '/') {
105+
p++;
106+
}
107+
size_t seg_len = (size_t)(p - seg_start);
108+
if (seg_len == 1 && seg_start[0] == '.') {
109+
continue;
110+
}
111+
if (seg_len == 2 && seg_start[0] == '.' && seg_start[1] == '.') {
112+
char *last = strrchr(buf, '/');
113+
if (last) {
114+
*last = '\0';
115+
} else {
116+
buf[0] = '\0';
117+
}
118+
continue;
119+
}
120+
size_t cur = strlen(buf);
121+
if (cur > 0) {
122+
buf[cur++] = '/';
123+
}
124+
memcpy(buf + cur, seg_start, seg_len);
125+
buf[cur + seg_len] = '\0';
126+
}
127+
128+
if (trailing_slash) {
129+
size_t cur = strlen(buf);
130+
buf[cur] = '/';
131+
buf[cur + 1] = '\0';
84132
}
85-
snprintf(result, dp_len + 1 + t_len + 1, "%s/%s", dir_prefix, t);
86-
return result;
133+
return buf;
87134
}
88135

89136
/* qsort comparator: alias entries by alias_prefix length, descending. */

tests/test_path_alias.c

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,53 @@ TEST(path_alias_loader_monorepo) {
289289
PASS();
290290
}
291291

292+
/* ── Monorepo alias climbing out of its tsconfig's directory (#730) ── */
293+
294+
TEST(path_alias_loader_monorepo_dotdot_climb) {
295+
char tmpl[256];
296+
snprintf(tmpl, sizeof(tmpl), "/tmp/cbm_palias_climb_XXXXXX");
297+
char *root = cbm_mkdtemp(tmpl);
298+
ASSERT_NOT_NULL(root);
299+
300+
char sub[512];
301+
snprintf(sub, sizeof(sub), "%s/apps", root);
302+
cbm_mkdir(sub);
303+
snprintf(sub, sizeof(sub), "%s/apps/web", root);
304+
cbm_mkdir(sub);
305+
306+
char path[512];
307+
snprintf(path, sizeof(path), "%s/apps/web/tsconfig.json", root);
308+
ASSERT_EQ(write_file(path,
309+
"{\n \"compilerOptions\": {\n \"paths\": {\n"
310+
" \"@shared/*\": [\"../../packages/shared/src/*\"]\n"
311+
" }\n }\n}\n"),
312+
0);
313+
314+
cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root);
315+
ASSERT_NOT_NULL(coll);
316+
317+
const cbm_path_alias_map_t *m =
318+
cbm_path_alias_find_for_file(coll, "apps/web/src/feature/x.ts");
319+
ASSERT_NOT_NULL(m);
320+
char *r = cbm_path_alias_resolve(m, "@shared/utils");
321+
ASSERT_NOT_NULL(r);
322+
/* "../.." from apps/web climbs to repo root, then descends into
323+
* packages/shared/src — not the literal (unmatchable) "apps/web/../../..." */
324+
ASSERT_STR_EQ(r, "packages/shared/src/utils");
325+
free(r);
326+
327+
cbm_path_alias_collection_free(coll);
328+
329+
snprintf(path, sizeof(path), "%s/apps/web/tsconfig.json", root);
330+
unlink(path);
331+
snprintf(path, sizeof(path), "%s/apps/web", root);
332+
rmdir(path);
333+
snprintf(path, sizeof(path), "%s/apps", root);
334+
rmdir(path);
335+
rmdir(root);
336+
PASS();
337+
}
338+
292339
/* ── Loader returns NULL when no configs found ─────────────────── */
293340

294341
TEST(path_alias_loader_no_configs) {
@@ -315,5 +362,6 @@ void suite_path_alias(void) {
315362
RUN_TEST(path_alias_null_safety);
316363
RUN_TEST(path_alias_find_for_file_nearest_ancestor);
317364
RUN_TEST(path_alias_loader_monorepo);
365+
RUN_TEST(path_alias_loader_monorepo_dotdot_climb);
318366
RUN_TEST(path_alias_loader_no_configs);
319367
}

0 commit comments

Comments
 (0)