Skip to content

Commit bceebbd

Browse files
committed
fix(js): bind calls through a namespace import to re-exported members
`import * as ns from './m'; ns.fn()` bound only when m defined fn itself. When m re-exported fn (`export * from`, `export { a as fn } from`), the registry built the candidate m.fn, found nothing and left the call without an edge. The JS/TS import map now adds a key "ns.member" for each call `ns.member()` through a namespace (or require) binding. The value is the definition found by the existing re-export walker; no key is added when it finds none or more than one. The registry looks up the whole callee as a key before it splits it at the first dot. The walker's visited set is now keyed on (file, name) instead of file. A barrel with both `export * from './m'` and `export { a as b } from './m'` searched m once for `b` and then skipped m when it looked for `a`, so the renamed member was lost. Import map writes now reserve capacity first, because member keys can exceed the one-per-edge estimate. Tested: new lrp_js_namespace_import_member_calls. Before the fix, the `export *` and renamed cases had no edge. Suites path_alias, edge_imports, extraction_imports, lsp_resolution_probe, mcp, registry, incremental: 896 passed, 4 skipped. Bake-off TS corpus unchanged: 241 of 243 indexed callers, 1 wrong edge. Signed-off-by: chidev <[email protected]>
1 parent cf70a88 commit bceebbd

3 files changed

Lines changed: 235 additions & 5 deletions

File tree

‎src/pipeline/pass_lsp_cross.c‎

Lines changed: 149 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,7 +1468,19 @@ static bool pxc_edge_string_prop(const char *json, const char *key, char *out, s
14681468
return true;
14691469
}
14701470

1471-
/* `visited` holds barrel file QNs already searched for `name`: barrels form
1471+
static void pxc_free_visit_key(const char *key, void *value, void *userdata) {
1472+
(void)key;
1473+
(void)userdata;
1474+
free(value);
1475+
}
1476+
1477+
/* Free a `visited` table built by pxc_barrel_symbol_qn and its owned keys. */
1478+
static void pxc_barrel_visited_free(CBMHashTable *visited) {
1479+
cbm_ht_foreach(visited, pxc_free_visit_key, NULL);
1480+
cbm_ht_free(visited);
1481+
}
1482+
1483+
/* `visited` holds (barrel file QN, name) pairs already searched: barrels form
14721484
* DAGs (and cycles) where one lib index is reachable through many paths, and
14731485
* re-walking it on each path is exponential in the depth cap. */
14741486
static char *pxc_barrel_symbol_qn(const cbm_gbuf_t *gbuf, const char *project_name,
@@ -1501,10 +1513,24 @@ static char *pxc_barrel_symbol_qn(const cbm_gbuf_t *gbuf, const char *project_na
15011513
free(file_qn);
15021514
}
15031515
}
1504-
if (!file_node || cbm_ht_has(visited, file_node->qualified_name)) {
1516+
if (!file_node || !file_node->qualified_name) {
1517+
return NULL;
1518+
}
1519+
/* Key on file and name: a named re-export renames the symbol, so one
1520+
* file can be searched for two names in one walk (`export *` and
1521+
* `export { a as b }` from the same module). The key owns its bytes and
1522+
* is the stored value; pxc_barrel_visited_free releases it. */
1523+
size_t vk_len = strlen(file_node->qualified_name) + 1 + strlen(name) + 1;
1524+
char *visit_key = (char *)malloc(vk_len);
1525+
if (!visit_key) {
15051526
return NULL;
15061527
}
1507-
cbm_ht_set(visited, file_node->qualified_name, (void *)file_node);
1528+
snprintf(visit_key, vk_len, "%s\x1f%s", file_node->qualified_name, name);
1529+
if (cbm_ht_has(visited, visit_key)) {
1530+
free(visit_key);
1531+
return NULL;
1532+
}
1533+
cbm_ht_set(visited, visit_key, visit_key);
15081534
char *defined = pxc_defined_symbol_qn(gbuf, project_name, rel, name);
15091535
if (defined) {
15101536
return defined;
@@ -1563,6 +1589,114 @@ static bool pxc_import_map_has_local(const char *const *keys, int count, const c
15631589
return false;
15641590
}
15651591

1592+
/* True when `local` is a JS/TS module-object binding: `import * as local`
1593+
* or `const local = require(...)`. Such an import has no member, is not a
1594+
* default import and is not a re-export. A local that also names a member
1595+
* import is not treated as one. */
1596+
static bool pxc_js_namespace_local(const CBMFileResult *result, const char *local) {
1597+
if (!result || !local) {
1598+
return false;
1599+
}
1600+
bool found = false;
1601+
for (int i = 0; i < result->imports.count; i++) {
1602+
const CBMImport *imp = &result->imports.items[i];
1603+
if (!imp->local_name || strcmp(imp->local_name, local) != 0) {
1604+
continue;
1605+
}
1606+
if (imp->member || imp->is_default || imp->reexport != CBM_REEXPORT_NONE ||
1607+
!cbm_import_binds_runtime_name(imp)) {
1608+
return false;
1609+
}
1610+
found = true;
1611+
}
1612+
return found;
1613+
}
1614+
1615+
/* A JS identifier: the member part of `ns.member`. */
1616+
static bool pxc_js_identifier(const char *s) {
1617+
if (!s || !s[0] || (s[0] >= '0' && s[0] <= '9')) {
1618+
return false;
1619+
}
1620+
for (const char *p = s; *p; p++) {
1621+
char c = *p;
1622+
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
1623+
c == '_' || c == '$')) {
1624+
return false;
1625+
}
1626+
}
1627+
return true;
1628+
}
1629+
1630+
/* Grow the parallel key/value arrays of an import map to hold `need` slots.
1631+
* Namespace members add entries beyond the one-per-edge first estimate, so
1632+
* every write in cbm_pxc_build_import_map_ex reserves first. */
1633+
static bool pxc_import_map_reserve(const char ***keys, const char ***vals, size_t *capacity,
1634+
size_t need) {
1635+
if (need <= *capacity) {
1636+
return true;
1637+
}
1638+
size_t grown = *capacity * 2 > need ? *capacity * 2 : need;
1639+
const char **nk = (const char **)realloc((void *)*keys, grown * sizeof(char *));
1640+
if (!nk) {
1641+
return false;
1642+
}
1643+
*keys = nk;
1644+
const char **nv = (const char **)realloc((void *)*vals, grown * sizeof(char *));
1645+
if (!nv) {
1646+
return false; /* keys grew, capacity did not: still consistent */
1647+
}
1648+
*vals = nv;
1649+
*capacity = grown;
1650+
return true;
1651+
}
1652+
1653+
/* Calls through a namespace import. For each call `local.member()` in the
1654+
* file, follow the imported module (and its re-export chain, as for a named
1655+
* import) to the one file that defines `member`, and add the key
1656+
* "local.member" with that definition's QN. The registry looks up the whole
1657+
* callee as a key before it splits it, so `ns.fn()` binds to fn's defining
1658+
* file even when the imported module only re-exports fn. Nothing is added
1659+
* when no definition, or more than one, is found. */
1660+
static void pxc_add_js_namespace_members(const cbm_gbuf_t *gbuf, const char *project_name,
1661+
const CBMFileResult *result, const char *local,
1662+
const cbm_gbuf_node_t *target, const char ***keys,
1663+
const char ***vals, int *count, size_t *capacity) {
1664+
if (!result || !target || !target->label ||
1665+
(strcmp(target->label, "Module") != 0 && strcmp(target->label, "Folder") != 0)) {
1666+
return;
1667+
}
1668+
size_t local_len = strlen(local);
1669+
for (int i = 0; i < result->calls.count; i++) {
1670+
const char *callee = result->calls.items[i].callee_name;
1671+
if (!callee || strncmp(callee, local, local_len) != 0 || callee[local_len] != '.' ||
1672+
!pxc_js_identifier(callee + local_len + 1) ||
1673+
pxc_import_map_has_local(*keys, *count, callee)) {
1674+
continue;
1675+
}
1676+
CBMHashTable *visited = cbm_ht_create(16);
1677+
char *qn = visited ? pxc_barrel_symbol_qn(gbuf, project_name, target,
1678+
callee + local_len + 1, 0, visited)
1679+
: NULL;
1680+
pxc_barrel_visited_free(visited);
1681+
if (!qn) {
1682+
continue;
1683+
}
1684+
char *key = strdup(callee);
1685+
if (!key) {
1686+
free(qn);
1687+
continue;
1688+
}
1689+
if (!pxc_import_map_reserve(keys, vals, capacity, (size_t)*count + 1)) {
1690+
free(key);
1691+
free(qn);
1692+
return;
1693+
}
1694+
(*keys)[*count] = key;
1695+
(*vals)[*count] = qn;
1696+
(*count)++;
1697+
}
1698+
}
1699+
15661700
/* Recover a Python import directly from extraction metadata when graph import
15671701
* materialization produced no edge. Prefer an already materialized exact node
15681702
* (including the shared project-prefix rule); otherwise accept only the
@@ -1710,17 +1844,26 @@ int cbm_pxc_build_import_map_ex(const cbm_gbuf_t *gbuf, const char *project_name
17101844
CBMHashTable *visited = direct ? NULL : cbm_ht_create(16);
17111845
if (visited) {
17121846
barrel = pxc_barrel_symbol_qn(gbuf, project_name, target, local, 0, visited);
1713-
cbm_ht_free(visited);
1847+
pxc_barrel_visited_free(visited);
17141848
}
17151849
free(direct);
17161850
if (barrel) {
17171851
free(value);
17181852
value = barrel;
17191853
}
17201854
}
1855+
if (!pxc_import_map_reserve(&keys, &vals, &capacity, (size_t)count + 1)) {
1856+
free(local);
1857+
free(value);
1858+
continue;
1859+
}
17211860
keys[count] = local;
17221861
vals[count] = value;
17231862
count++;
1863+
if (pxc_is_js_family(lang) && pxc_js_namespace_local(result, local)) {
1864+
pxc_add_js_namespace_members(gbuf, project_name, result, local, target, &keys, &vals,
1865+
&count, &capacity);
1866+
}
17241867
}
17251868

17261869
/* IMPORTS edges are best-effort graph relationships. The cross-LSP still
@@ -1745,7 +1888,8 @@ int cbm_pxc_build_import_map_ex(const cbm_gbuf_t *gbuf, const char *project_name
17451888
if (!value)
17461889
continue;
17471890
char *local = strdup(imp->local_name);
1748-
if (!local) {
1891+
if (!local || !pxc_import_map_reserve(&keys, &vals, &capacity, (size_t)count + 1)) {
1892+
free(local);
17491893
free(value);
17501894
continue;
17511895
}

‎src/pipeline/registry.c‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1880,6 +1880,32 @@ static cbm_resolution_t resolve_import_map(const cbm_registry_t *r, const char *
18801880
return empty_result();
18811881
}
18821882

1883+
/* Whole-callee key. The JS/TS import map adds "ns.member" for a call
1884+
* through a namespace import whose member it followed to the defining
1885+
* file (cbm_pxc_build_import_map). It wins over the split lookup below,
1886+
* which only sees the imported module and misses re-exported members. */
1887+
if (suffix && suffix[0]) {
1888+
char whole[CBM_SZ_512];
1889+
int wn = snprintf(whole, sizeof(whole), "%s.%s", prefix, suffix);
1890+
const char *member_qn = NULL;
1891+
if (wn > 0 && (size_t)wn < sizeof(whole)) {
1892+
if (_import_map_cache) {
1893+
member_qn = (const char *)cbm_ht_get(_import_map_cache, whole);
1894+
} else {
1895+
for (int i = 0; i < map_count; i++) {
1896+
if (strcmp(keys[i], whole) == 0) {
1897+
member_qn = vals[i];
1898+
break;
1899+
}
1900+
}
1901+
}
1902+
}
1903+
const char *direct = member_qn ? cbm_ht_get_key(r->exact, member_qn) : NULL;
1904+
if (direct) {
1905+
return (cbm_resolution_t){direct, "import_map", CONF_IMPORT_MAP, REG_RESOLVED};
1906+
}
1907+
}
1908+
18831909
/* Build candidate: resolved.suffix or resolved.prefix.
18841910
* When the callee has a dot ("pkg.Func"), prefix="pkg" is the import key
18851911
* and suffix="Func" is the function name, so the target QN is

‎tests/test_lsp_resolution_probe.c‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,6 +1385,65 @@ TEST(lrp_ts_barrel_reexport_member_calls) {
13851385
PASS();
13861386
}
13871387

1388+
/* JS namespace imports. `import * as ns from './m'; ns.fn()` binds the
1389+
* call to m's `fn` (N1). When m only re-exports the name, the call binds
1390+
* through the re-export to the defining file: `export *` (N2), a renamed
1391+
* `export { a as b }` (N3) and a barrel folder's index.js (N4). A decoy
1392+
* function with the N2 name lives in another file, so a name guess cannot
1393+
* make N2 pass and the decoy must get no edge (D). */
1394+
TEST(lrp_js_namespace_import_member_calls) {
1395+
static const LRP_File f[] = {
1396+
{"src/lib/direct.js", "export function directFn(x) { return x + 1; }\n"},
1397+
{"src/lib/impl.js", "export function starFn(x) { return x * 2; }\n"
1398+
"export function innerFn(x) { return x - 1; }\n"},
1399+
{"src/lib/barrel.js", "export * from './impl';\n"
1400+
"export { innerFn as renamedFn } from './impl';\n"},
1401+
{"src/lib/pkg/pkgimpl.js", "export function pkgFn(x) { return x * 3; }\n"},
1402+
{"src/lib/pkg/index.js", "export { pkgFn } from './pkgimpl';\n"},
1403+
{"src/other/decoy.js", "export function starFn(x) { return 0; }\n"},
1404+
{"src/app.js", "import * as direct from './lib/direct';\n"
1405+
"import * as barrel from './lib/barrel';\n"
1406+
"import * as pkg from './lib/pkg';\n"
1407+
"export function run(x) {\n"
1408+
" const a = direct.directFn(x);\n"
1409+
" const b = barrel.starFn(x);\n"
1410+
" const c = barrel.renamedFn(x);\n"
1411+
" const d = pkg.pkgFn(x);\n"
1412+
" return a + b + c + d;\n"
1413+
"}\n"},
1414+
/* A namespace import before another import: its member entry fills
1415+
* the map's first-estimate slots, so the next import must grow it. */
1416+
{"src/two.js", "import * as direct from './lib/direct';\n"
1417+
"import { pkgFn } from './lib/pkg/pkgimpl';\n"
1418+
"export function runTwo(x) { return direct.directFn(x) + pkgFn(x); }\n"},
1419+
};
1420+
LRP_Proj lp;
1421+
cbm_store_t *store = lrp_index(&lp, f, (int)(sizeof(f) / sizeof(f[0])));
1422+
ASSERT_NOT_NULL(store);
1423+
1424+
int n1 = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "app.run", "direct.directFn");
1425+
int n2 = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "app.run", "impl.starFn");
1426+
int n3 = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "app.run", "impl.innerFn");
1427+
int n4 = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "app.run", "pkgimpl.pkgFn");
1428+
int d = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "app.run", "decoy.starFn");
1429+
int t1 = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "two.runTwo",
1430+
"direct.directFn");
1431+
int t2 = lrp_exact_edge_by_qn_suffix(store, lp.project, "CALLS", "two.runTwo",
1432+
"pkgimpl.pkgFn");
1433+
printf(" [js/namespace] N1=%d N2=%d N3=%d N4=%d decoy=%d T1=%d T2=%d\n", n1, n2, n3, n4, d,
1434+
t1, t2);
1435+
lrp_cleanup(&lp, store);
1436+
1437+
ASSERT_EQ(n1, 1);
1438+
ASSERT_EQ(n2, 1);
1439+
ASSERT_EQ(n3, 1);
1440+
ASSERT_EQ(n4, 1);
1441+
ASSERT_EQ(d, 0);
1442+
ASSERT_EQ(t1, 1);
1443+
ASSERT_EQ(t2, 1);
1444+
PASS();
1445+
}
1446+
13881447
/* ══════════════════════════════════════════════════════════════════════
13891448
* ─── GROUP 7: JAVA (lsp_cross NOT WIRED) ─────────────────────────────
13901449
* ══════════════════════════════════════════════════════════════════════
@@ -2033,6 +2092,7 @@ SUITE(lsp_resolution_probe) {
20332092
RUN_TEST(lrp_ts_s7_generic);
20342093
RUN_TEST(lrp_ts_s8_field_type_hint);
20352094
RUN_TEST(lrp_ts_barrel_reexport_member_calls);
2095+
RUN_TEST(lrp_js_namespace_import_member_calls);
20362096

20372097
/* ── Java (lsp_cross NOT WIRED) — S1 GREEN, S2–S8 RED reproductions ── */
20382098
RUN_TEST(lrp_java_s1_crossfile_call);

0 commit comments

Comments
 (0)