Files
codebase-memory-mcp/tests/test_path_alias.c
T
Peter Cox 10a9b83e27 fix(pipeline): resolve build-tool path aliases for cross-file imports (#243)
CALLS edges for cross-file function calls were mis-wired or missing in
TypeScript projects that use path aliases (e.g. @/lib/auth → src/lib/auth):

  1. resolve_relative_import only handled "./relative" paths, so
     "@/lib/auth"-style imports never reached IMPORTS edges. The import
     map stayed empty, Strategy 1 in cbm_registry_resolve failed, and
     resolution fell through to name-based lookups that pick the wrong
     target when functions share names across files.

  2. resolve_import_map built the candidate QN as the bare module QN
     when the callee had no dot ("requireAdmin" vs "pkg.Func"), instead
     of module_qn.requireAdmin. Even with a populated import map, bare
     calls still mis-resolved.

Fix:

  - New src/pipeline/path_alias.{h,c}: language-agnostic build-tool
    path-alias subsystem. Currently parses tsconfig.json and
    jsconfig.json compilerOptions.paths + baseUrl via the existing
    yyjson dependency (with ALLOW_COMMENTS|ALLOW_TRAILING_COMMAS for
    JSONC). The data model (cbm_path_alias_t / map / scope /
    collection) is generic; adding Vite/Webpack/Python loaders means
    writing a new file-parsing helper and registering it in
    find_alias_files — the resolver, collection, and pipeline
    integration do not change.

  - O(n log n) ordering: scope-by-dir-specificity and entry-by-
    prefix-specificity are sorted with qsort, replacing the O(n²)
    bubble sorts that the original draft used. With caps of 256
    entries per config and 256 configs per repo, the sort is
    effectively flat.

  - Resource ceilings with logging: directory recursion capped at
    32 levels; total config files capped at 256; per-config size
    capped at 64 KB. Cap hits emit cbm_log_warn so silent truncation
    on pathological monorepos shows up in the index log.

  - Walk hygiene: skips node_modules, .git, dist, build, .next,
    coverage, target.

  - Single integration point: cbm_pipeline_resolve_module gets a
    Strategy 1b between relative-import resolution and pkgmap so both
    the sequential and parallel pipelines pick up alias resolution
    automatically. Non-TS projects pay nothing — the loader returns
    NULL when no usable configs are found.

  - Layer 2 fix in registry.c: resolve_import_map now builds the
    candidate as resolved.prefix when suffix is NULL (one-line fix,
    matching comment).

  - Tests: 10 path_alias tests (basic wildcard, specificity, exact,
    extension stripping, baseUrl, NULL safety, nearest-ancestor scope
    selection, end-to-end loader against a tmp monorepo, no-configs
    case) plus a registry test for the bare-call fix. Full suite:
    2835 passed, 0 failed.

Cherry-picked and generalized from #243 (originally TypeScript-only
under tsconfig-named types). Renamed types (cbm_path_alias_scope_t /
collection_t in place of cbm_tsconfig_*) and centralised the load
into cbm_load_path_aliases so future loaders for other build tools
slot in without touching extraction or pipeline code.

Closes #243.

Co-authored-by: Peter Cox <info@petercox.ie>
2026-05-08 23:45:32 +02:00

317 lines
11 KiB
C

/*
* test_path_alias.c -- Tests for build-tool path alias resolution.
*
* Covers the in-memory resolver (cbm_path_alias_resolve), the
* directory-scoped collection lookup (cbm_path_alias_find_for_file),
* and the resource-cap behaviour. Filesystem-based loading is exercised
* indirectly via the integration test that builds a tmp tsconfig tree.
*/
#include "test_framework.h"
#include "../src/pipeline/path_alias.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
/* Build a path alias map programmatically (no file I/O), respecting the
* specificity ordering invariant the loader establishes via qsort. */
static cbm_path_alias_map_t *make_map(const char *base_url, int count, ...) {
cbm_path_alias_map_t *map = calloc(1, sizeof(*map));
map->base_url = base_url ? strdup(base_url) : NULL;
map->entries = calloc((size_t)count, sizeof(cbm_path_alias_t));
map->count = count;
va_list args;
va_start(args, count);
for (int i = 0; i < count; i++) {
const char *alias_pattern = va_arg(args, const char *);
const char *target_pattern = va_arg(args, const char *);
const char *star = strchr(alias_pattern, '*');
if (star) {
map->entries[i].has_wildcard = true;
map->entries[i].alias_prefix =
strndup(alias_pattern, (size_t)(star - alias_pattern));
map->entries[i].alias_suffix = strdup(star + 1);
} else {
map->entries[i].has_wildcard = false;
map->entries[i].alias_prefix = strdup(alias_pattern);
map->entries[i].alias_suffix = strdup("");
}
const char *tstar = strchr(target_pattern, '*');
if (tstar) {
map->entries[i].target_prefix =
strndup(target_pattern, (size_t)(tstar - target_pattern));
map->entries[i].target_suffix = strdup(tstar + 1);
} else {
map->entries[i].target_prefix = strdup(target_pattern);
map->entries[i].target_suffix = strdup("");
}
}
va_end(args);
/* Mimic the loader's specificity sort. */
for (int i = 0; i < count - 1; i++) {
for (int j = i + 1; j < count; j++) {
size_t li = strlen(map->entries[i].alias_prefix);
size_t lj = strlen(map->entries[j].alias_prefix);
if (lj > li) {
cbm_path_alias_t tmp = map->entries[i];
map->entries[i] = map->entries[j];
map->entries[j] = tmp;
}
}
}
return map;
}
/* The map produced by make_map() owns all heap memory the same way the
* loader does, so the public free routine on the wrapping collection
* works equivalently — but tests build naked maps, so use this helper. */
static void free_map(cbm_path_alias_map_t *map) {
if (!map) {
return;
}
for (int i = 0; i < map->count; i++) {
free(map->entries[i].alias_prefix);
free(map->entries[i].alias_suffix);
free(map->entries[i].target_prefix);
free(map->entries[i].target_suffix);
}
free(map->entries);
free(map->base_url);
free(map);
}
/* ── Basic wildcard alias ──────────────────────────────────────── */
TEST(path_alias_at_wildcard) {
cbm_path_alias_map_t *m = make_map(NULL, 1, "@/*", "src/*");
char *r = cbm_path_alias_resolve(m, "@/lib/auth");
ASSERT_NOT_NULL(r);
ASSERT_STR_EQ(r, "src/lib/auth");
free(r);
free_map(m);
PASS();
}
TEST(path_alias_at_nested) {
cbm_path_alias_map_t *m = make_map(NULL, 1, "@/*", "src/*");
char *r = cbm_path_alias_resolve(m, "@/components/Button");
ASSERT_NOT_NULL(r);
ASSERT_STR_EQ(r, "src/components/Button");
free(r);
free_map(m);
PASS();
}
/* ── Specificity ordering: longest matching prefix wins ────────── */
TEST(path_alias_specificity_longest_first) {
// @/lib/* must beat @/* even though @/* would also match.
cbm_path_alias_map_t *m =
make_map(NULL, 2, "@/*", "src/*", "@/lib/*", "src/shared/lib/*");
char *r = cbm_path_alias_resolve(m, "@/lib/auth");
ASSERT_NOT_NULL(r);
ASSERT_STR_EQ(r, "src/shared/lib/auth");
free(r);
/* Non-/lib paths still match the broader rule. */
char *r2 = cbm_path_alias_resolve(m, "@/components/X");
ASSERT_NOT_NULL(r2);
ASSERT_STR_EQ(r2, "src/components/X");
free(r2);
free_map(m);
PASS();
}
/* ── Exact match (no wildcard) ─────────────────────────────────── */
TEST(path_alias_exact_match) {
cbm_path_alias_map_t *m = make_map(NULL, 1, "@app/config", "src/config/index");
char *r = cbm_path_alias_resolve(m, "@app/config");
ASSERT_NOT_NULL(r);
ASSERT_STR_EQ(r, "src/config/index");
free(r);
/* Anything else under @app/ should not match. */
char *miss = cbm_path_alias_resolve(m, "@app/other");
ASSERT_NULL(miss);
free_map(m);
PASS();
}
/* ── Extension stripping ───────────────────────────────────────── */
TEST(path_alias_strips_ext) {
cbm_path_alias_map_t *m = make_map(NULL, 1, "@/*", "src/*.ts");
char *r = cbm_path_alias_resolve(m, "@/lib/auth");
ASSERT_NOT_NULL(r);
ASSERT_STR_EQ(r, "src/lib/auth");
free(r);
free_map(m);
PASS();
}
/* ── baseUrl fallback for non-relative, non-package imports ────── */
TEST(path_alias_baseurl_fallback) {
cbm_path_alias_map_t *m = make_map("src", 0);
/* Looks like a sub-path → resolve against baseUrl. */
char *r = cbm_path_alias_resolve(m, "lib/auth");
ASSERT_NOT_NULL(r);
ASSERT_STR_EQ(r, "src/lib/auth");
free(r);
/* Bare package name → not a baseUrl candidate. */
char *miss = cbm_path_alias_resolve(m, "react");
ASSERT_NULL(miss);
free_map(m);
PASS();
}
/* ── NULL safety ───────────────────────────────────────────────── */
TEST(path_alias_null_safety) {
ASSERT_NULL(cbm_path_alias_resolve(NULL, "anything"));
cbm_path_alias_map_t *m = make_map(NULL, 1, "@/*", "src/*");
ASSERT_NULL(cbm_path_alias_resolve(m, NULL));
free_map(m);
/* Free of NULL collection is a no-op, not a crash. */
cbm_path_alias_collection_free(NULL);
PASS();
}
/* ── Collection lookup: nearest-ancestor scope wins ───────────── */
TEST(path_alias_find_for_file_nearest_ancestor) {
/* Build a synthetic collection by hand: two scopes, "" (root) and
* "apps/manager". A file inside apps/manager/src/... must pick the
* deeper scope; a file under packages/utils must fall back to root. */
cbm_path_alias_collection_t *coll = calloc(1, sizeof(*coll));
coll->scopes = calloc(2, sizeof(cbm_path_alias_scope_t));
coll->count = 2;
/* Order matters: most specific first (loader does this via qsort). */
coll->scopes[0].dir_prefix = strdup("apps/manager");
coll->scopes[0].map = make_map(NULL, 1, "@/*", "src/*");
coll->scopes[1].dir_prefix = strdup("");
coll->scopes[1].map = make_map(NULL, 1, "@root/*", "shared/*");
const cbm_path_alias_map_t *m1 =
cbm_path_alias_find_for_file(coll, "apps/manager/src/lib/auth.ts");
ASSERT_NOT_NULL(m1);
ASSERT_EQ(m1->count, 1);
ASSERT_STR_EQ(m1->entries[0].alias_prefix, "@/");
const cbm_path_alias_map_t *m2 =
cbm_path_alias_find_for_file(coll, "packages/utils/index.ts");
ASSERT_NOT_NULL(m2);
ASSERT_EQ(m2->count, 1);
ASSERT_STR_EQ(m2->entries[0].alias_prefix, "@root/");
cbm_path_alias_collection_free(coll);
PASS();
}
/* ── End-to-end via the loader: real tsconfig in a tmp dir ─────── */
static int write_file(const char *path, const char *content) {
FILE *f = fopen(path, "w");
if (!f) {
return -1;
}
size_t len = strlen(content);
int rc = fwrite(content, 1, len, f) == len ? 0 : -1;
fclose(f);
return rc;
}
TEST(path_alias_loader_monorepo) {
char tmpl[] = "/tmp/cbm_palias_XXXXXX";
char *root = mkdtemp(tmpl);
ASSERT_NOT_NULL(root);
char sub[512];
snprintf(sub, sizeof(sub), "%s/apps", root);
mkdir(sub, 0755);
snprintf(sub, sizeof(sub), "%s/apps/manager", root);
mkdir(sub, 0755);
char path[512];
snprintf(path, sizeof(path), "%s/tsconfig.json", root);
ASSERT_EQ(write_file(path,
"{\n \"compilerOptions\": {\n \"paths\": {\n"
" \"@root/*\": [\"shared/*\"]\n }\n }\n}\n"),
0);
snprintf(path, sizeof(path), "%s/apps/manager/tsconfig.json", root);
ASSERT_EQ(write_file(path,
"{\n // monorepo subpackage\n \"compilerOptions\": {\n"
" \"paths\": {\n \"@/*\": [\"./src/*\"]\n }\n },\n}\n"),
0);
cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root);
ASSERT_NOT_NULL(coll);
ASSERT_EQ(coll->count, 2);
/* sub-package file picks up its own tsconfig. */
const cbm_path_alias_map_t *m =
cbm_path_alias_find_for_file(coll, "apps/manager/src/feature/x.ts");
ASSERT_NOT_NULL(m);
char *r = cbm_path_alias_resolve(m, "@/lib/auth");
ASSERT_NOT_NULL(r);
/* Target paths in the sub-tsconfig are dir_prefix-relative. */
ASSERT_STR_EQ(r, "apps/manager/src/lib/auth");
free(r);
/* Root file falls back to the root tsconfig's aliases. */
const cbm_path_alias_map_t *m2 = cbm_path_alias_find_for_file(coll, "scripts/build.ts");
ASSERT_NOT_NULL(m2);
char *r2 = cbm_path_alias_resolve(m2, "@root/utils");
ASSERT_NOT_NULL(r2);
ASSERT_STR_EQ(r2, "shared/utils");
free(r2);
cbm_path_alias_collection_free(coll);
/* Cleanup tmp tree. */
snprintf(path, sizeof(path), "%s/apps/manager/tsconfig.json", root);
unlink(path);
snprintf(path, sizeof(path), "%s/tsconfig.json", root);
unlink(path);
snprintf(path, sizeof(path), "%s/apps/manager", 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) {
char tmpl[] = "/tmp/cbm_palias_empty_XXXXXX";
char *root = mkdtemp(tmpl);
ASSERT_NOT_NULL(root);
cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root);
ASSERT_NULL(coll);
rmdir(root);
PASS();
}
void suite_path_alias(void);
void suite_path_alias(void) {
RUN_TEST(path_alias_at_wildcard);
RUN_TEST(path_alias_at_nested);
RUN_TEST(path_alias_specificity_longest_first);
RUN_TEST(path_alias_exact_match);
RUN_TEST(path_alias_strips_ext);
RUN_TEST(path_alias_baseurl_fallback);
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_no_configs);
}