diff --git a/Makefile.cbm b/Makefile.cbm index de6ad40ff..892025a5e 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -355,7 +355,7 @@ TEST_DISCOVER_SRCS = \ TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c -TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c +TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_workspaces.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c TEST_WATCHER_SRCS = tests/test_watcher.c diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 68d7d6041..8f2444902 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -817,9 +817,15 @@ static void merge_pkg_entries(cbm_pipeline_ctx_t *ctx, cbm_pkg_entries_t *pkg_en /* Supplement with a repo-wide filesystem walk so manifests filtered * by the main discoverer (package.json, composer.json — in * IGNORED_JSON_FILES) still feed pkgmap. Append into worker 0's - * array so the existing merge below sees them. */ - cbm_pkgmap_scan_repo(ctx->repo_path, &pkg_entries[0], ctx->excluded_dirs, ctx->excluded_count); + * array so the existing merge below sees them. The same walk populates + * npm-workspace roots/candidates (#271); this runs in the sequential + * post-join region, so no worker races on `ws`. */ + cbm_workspaces_t *ws = cbm_workspaces_new(); + cbm_pkgmap_scan_repo(ctx->repo_path, &pkg_entries[0], ctx->excluded_dirs, ctx->excluded_count, + ws); cbm_pipeline_set_pkgmap(cbm_pkgmap_build(pkg_entries, worker_count, ctx->project_name)); + cbm_workspaces_finalize(ws); + cbm_pipeline_set_workspaces(ws); for (int i = 0; i < worker_count; i++) { cbm_pkg_entries_free(&pkg_entries[i]); } diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index 96b6a78b6..100d91209 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -845,7 +845,8 @@ static bool pkgmap_is_reparse_point(const char *abs_path) { * it hang. On Windows we additionally skip reparse points before * descending as a best-effort early-out. */ static int pkgmap_walk_dir(const char *abs_dir, const char *rel_dir, cbm_pkg_entries_t *entries, - int depth, char **excluded_dirs, int excluded_count) { + int depth, char **excluded_dirs, int excluded_count, + cbm_workspaces_t *ws) { if (depth >= PKGMAP_WALK_MAX_DEPTH) { cbm_log_info("pkgmap.walk", "depth_cap", rel_dir && rel_dir[0] ? rel_dir : "."); return 0; @@ -888,7 +889,7 @@ static int pkgmap_walk_dir(const char *abs_dir, const char *rel_dir, cbm_pkg_ent } #endif parsed += pkgmap_walk_dir(abs_path, rel_path, entries, depth + 1, excluded_dirs, - excluded_count); + excluded_count, ws); continue; } if (!S_ISREG(st.st_mode)) { @@ -905,6 +906,8 @@ static int pkgmap_walk_dir(const char *abs_dir, const char *rel_dir, cbm_pkg_ent if (cbm_pkgmap_try_parse(name, rel_path, source, source_len, entries)) { parsed++; } + /* Same in-memory buffer feeds workspace detection — no extra file IO. */ + cbm_workspace_try_detect(name, rel_path, source, source_len, ws); free(source); } cbm_closedir(dir); @@ -923,11 +926,11 @@ static int pkgmap_walk_dir(const char *abs_dir, const char *rel_dir, cbm_pkg_ent * This is what lets bare workspace imports (e.g. "@org/pkg" declared in * an ignored package.json) resolve on Windows as well as POSIX. */ int cbm_pkgmap_scan_repo(const char *repo_path, cbm_pkg_entries_t *entries, char **excluded_dirs, - int excluded_count) { + int excluded_count, cbm_workspaces_t *ws) { if (!repo_path || !entries) { return 0; } - int parsed = pkgmap_walk_dir(repo_path, "", entries, 0, excluded_dirs, excluded_count); + int parsed = pkgmap_walk_dir(repo_path, "", entries, 0, excluded_dirs, excluded_count, ws); cbm_log_info("pkgmap.scan_repo", "manifests", pkgmap_itoa(parsed)); return parsed; } @@ -965,7 +968,8 @@ CBMHashTable *cbm_pkgmap_build_from_files(const cbm_file_info_t *files, int file * Falls back to the files[]-only behaviour if repo_path is NULL. */ CBMHashTable *cbm_pkgmap_build_from_repo(const char *repo_path, const cbm_file_info_t *files, int file_count, const char *project_name, - char **excluded_dirs, int excluded_count) { + char **excluded_dirs, int excluded_count, + cbm_workspaces_t *ws) { cbm_pkg_entries_t entries; cbm_pkg_entries_init(&entries); @@ -986,10 +990,11 @@ CBMHashTable *cbm_pkgmap_build_from_repo(const char *repo_path, const cbm_file_i continue; } cbm_pkgmap_try_parse(basename, files[i].rel_path, source, source_len, &entries); + cbm_workspace_try_detect(basename, files[i].rel_path, source, source_len, ws); free(source); } - int from_walk = cbm_pkgmap_scan_repo(repo_path, &entries, excluded_dirs, excluded_count); + int from_walk = cbm_pkgmap_scan_repo(repo_path, &entries, excluded_dirs, excluded_count, ws); cbm_log_info("pkgmap.scan", "manifests_from_files", pkgmap_itoa(from_files), "manifests_from_walk", pkgmap_itoa(from_walk), "entries", pkgmap_itoa(entries.count)); @@ -1012,6 +1017,250 @@ void cbm_pkgmap_free(CBMHashTable *pkgmap) { cbm_ht_free(pkgmap); } +/* ── npm workspaces (#271, Phase 1) ──────────────────────────────── */ + +cbm_workspaces_t *cbm_workspaces_new(void) { + return (cbm_workspaces_t *)calloc(CBM_ALLOC_ONE, sizeof(cbm_workspaces_t)); +} + +/* Normalize one npm workspace glob into a gitignore-engine pattern: strip a + * leading "./" and any trailing '/', drop empty/"." patterns. A leading '!' + * (negation) is preserved for the gitignore engine to apply. Brace expansion + * {a,b} is unsupported in Phase 1 — such a pattern is passed through (it then + * only matches a literal "{a,b}" directory, i.e. is effectively inert) and + * debug-logged. Writes into `out` (size outsz). Returns false to skip. */ +static bool ws_normalize_pattern(const char *pat, char *out, size_t outsz) { + if (!pat) { + return false; + } + const char *p = pat; + bool negated = false; + if (*p == '!') { + negated = true; + p++; + } + if (p[0] == '.' && p[1] == '/') { + p += PAIR_LEN; + } + char body[PKGMAP_PATH_BUF]; + snprintf(body, sizeof(body), "%s", p); + size_t bl = strlen(body); + while (bl > 0 && body[bl - SKIP_ONE] == '/') { + body[--bl] = '\0'; + } + if (bl == 0 || strcmp(body, ".") == 0) { + return false; + } + if (strchr(body, '{')) { + cbm_log_debug("workspace.brace_unsupported", "pattern", body); + } + /* npm workspace globs are ROOT-RELATIVE. Anchor every pattern with a + * leading '/' so the gitignore engine treats it as rooted (gi_add_pattern + * strips the '/' and sets rooted=true); otherwise a slash-less pattern like + * "*" or "docs" would match a candidate directory at ANY depth. The '!' + * negation prefix stays outermost so it is parsed before the anchor. */ + snprintf(out, outsz, "%s/%s", negated ? "!" : "", body); + return true; +} + +/* Record a member candidate (name → dir). Takes ownership of `dir`. */ +static void ws_add_candidate(cbm_workspaces_t *ws, const char *name, char *dir) { + if (ws->cand_count >= CBM_WS_MAX_MEMBERS) { + static bool warned = false; /* warn once, not once per over-cap manifest */ + if (!warned) { + cbm_log_warn("workspace.members.cap_hit", "cap", pkgmap_itoa(CBM_WS_MAX_MEMBERS)); + warned = true; + } + free(dir); + return; + } + if (ws->cand_count >= ws->cand_cap) { + int new_cap = ws->cand_cap == 0 ? PKGMAP_INIT_CAP : ws->cand_cap * PAIR_LEN; + cbm_ws_candidate_t *tmp = realloc(ws->cands, (size_t)new_cap * sizeof(cbm_ws_candidate_t)); + if (!tmp) { + free(dir); + return; + } + ws->cands = tmp; + ws->cand_cap = new_cap; + } + ws->cands[ws->cand_count].name = strdup(name); + ws->cands[ws->cand_count].dir = dir; /* takes ownership */ + ws->cand_count++; +} + +/* Record a workspace root: compile `joined_patterns` (a '\n'-delimited list of + * normalized globs) via the gitignore engine. Takes ownership of `root_dir`. + * Returns true if a root was added. */ +static bool ws_add_root(cbm_workspaces_t *ws, char *root_dir, const char *joined_patterns) { + if (!root_dir) { + return false; + } + if (ws->root_count >= CBM_WS_MAX_ROOTS) { + cbm_log_warn("workspace.roots.cap_hit", "cap", pkgmap_itoa(CBM_WS_MAX_ROOTS)); + free(root_dir); + return false; + } + cbm_gitignore_t *gi = cbm_gitignore_parse(joined_patterns); + if (!gi) { + free(root_dir); + return false; + } + ws->roots[ws->root_count].root_dir = root_dir; /* takes ownership */ + ws->roots[ws->root_count].patterns = gi; + ws->root_count++; + return true; +} + +bool cbm_workspace_try_detect(const char *basename, const char *rel_path, const char *source, + int source_len, cbm_workspaces_t *ws) { + if (!ws || !basename || !source || source_len <= 0) { + return false; + } + if (strcmp(basename, "package.json") != 0) { + return false; + } + yyjson_doc *doc = yyjson_read(source, (size_t)source_len, 0); + if (!doc) { + return false; + } + yyjson_val *root = yyjson_doc_get_root(doc); + if (!yyjson_is_obj(root)) { + yyjson_doc_free(doc); + return false; + } + + /* Member candidate: any named manifest is a potential workspace member. + * Recorded independently of root detection so walk order doesn't matter + * (a member may be parsed before its declaring root). */ + yyjson_val *name_val = yyjson_obj_get(root, "name"); + if (yyjson_is_str(name_val)) { + const char *name = yyjson_get_str(name_val); + if (name && name[0]) { + ws_add_candidate(ws, name, path_dirname(rel_path)); + } + } + + /* Workspace root: `workspaces` is an array or {"packages":[…]}. */ + yyjson_val *wsval = yyjson_obj_get(root, "workspaces"); + yyjson_val *arr = NULL; + if (yyjson_is_arr(wsval)) { + arr = wsval; + } else if (yyjson_is_obj(wsval)) { + arr = yyjson_obj_get(wsval, "packages"); + } + bool has_ws = false; + if (yyjson_is_arr(arr)) { + /* Join normalized globs with '\n' for the gitignore engine (native + * '*'/'**'/'!'/charclass support). */ + char joined[PKGMAP_PATH_BUF * PAIR_LEN * PAIR_LEN]; + size_t used = 0; + joined[0] = '\0'; + yyjson_val *pv; + yyjson_arr_iter it; + yyjson_arr_iter_init(arr, &it); + while ((pv = yyjson_arr_iter_next(&it)) != NULL) { + if (!yyjson_is_str(pv)) { + continue; + } + char norm[PKGMAP_PATH_BUF]; + if (!ws_normalize_pattern(yyjson_get_str(pv), norm, sizeof(norm))) { + continue; + } + int n = snprintf(joined + used, sizeof(joined) - used, "%s%s", used ? "\n" : "", norm); + if (n < 0 || (size_t)n >= sizeof(joined) - used) { + /* pattern list would overflow the join buffer — stop here; the + * bytes already written stay valid. */ + cbm_log_debug("workspace.pattern_buffer_full", "manifest", rel_path); + break; + } + used += (size_t)n; + } + if (used > 0) { + has_ws = ws_add_root(ws, path_dirname(rel_path), joined); + } + } + + yyjson_doc_free(doc); + return has_ws; +} + +static void ws_free_member(const char *key, void *value, void *userdata) { + (void)userdata; + free((void *)key); + free(value); +} + +void cbm_workspaces_finalize(cbm_workspaces_t *ws) { + if (!ws || ws->members) { + return; /* NULL-safe + idempotent */ + } + ws->members = cbm_ht_create(PKGMAP_HT_INIT); + if (!ws->members) { + return; + } + for (int c = 0; c < ws->cand_count; c++) { + const char *name = ws->cands[c].name; + const char *dir = ws->cands[c].dir; + if (!name || !dir) { + continue; + } + for (int r = 0; r < ws->root_count; r++) { + const cbm_ws_root_t *root = &ws->roots[r]; + /* Compute `dir` relative to the root, then glob-match. A repo-root + * workspace (root_dir="") matches the member dir directly. */ + const char *rel = NULL; + if (root->root_dir[0] == '\0') { + rel = dir; + } else { + size_t rl = strlen(root->root_dir); + if (strncmp(dir, root->root_dir, rl) == 0 && dir[rl] == '/') { + rel = dir + rl + SKIP_ONE; + } + } + if (!rel || !rel[0]) { + continue; + } + if (cbm_gitignore_matches(root->patterns, rel, /*is_dir=*/true)) { + if (!cbm_ht_has(ws->members, name)) { /* first-wins */ + cbm_ht_set(ws->members, strdup(name), strdup(dir)); + } + break; + } + } + } + /* Separate buffers: pkgmap_itoa returns one shared thread-local buffer, so + * three calls in a single log line would all render the last value. */ + char members_s[PKGMAP_ITOA_BUF]; + char roots_s[PKGMAP_ITOA_BUF]; + char cands_s[PKGMAP_ITOA_BUF]; + snprintf(members_s, sizeof(members_s), "%d", (int)cbm_ht_count(ws->members)); + snprintf(roots_s, sizeof(roots_s), "%d", ws->root_count); + snprintf(cands_s, sizeof(cands_s), "%d", ws->cand_count); + cbm_log_info("workspace.finalize", "members", members_s, "roots", roots_s, "candidates", + cands_s); +} + +void cbm_workspaces_free(cbm_workspaces_t *ws) { + if (!ws) { + return; + } + for (int r = 0; r < ws->root_count; r++) { + free(ws->roots[r].root_dir); + cbm_gitignore_free(ws->roots[r].patterns); + } + for (int c = 0; c < ws->cand_count; c++) { + free(ws->cands[c].name); + free(ws->cands[c].dir); + } + free(ws->cands); + if (ws->members) { + cbm_ht_foreach(ws->members, ws_free_member, NULL); + cbm_ht_free(ws->members); + } + free(ws); +} + /* ── Resolver ──────────────────────────────────────────────────── */ /* Try slash-based prefix matching (Go: github.com/foo/bar/pkg/utils). @@ -1343,6 +1592,79 @@ static const cbm_gbuf_node_t *resolve_sibling_file(const cbm_pipeline_ctx_t *ctx return found; } +/* Strategy 1c (#271): resolve a bare npm-workspace import (`@org/a` or + * `@org/a/utils/helper`) to a REAL in-graph source file. The pkgmap already + * maps `@org/a` → the package's declared entry QN, but when that entry points + * at an un-built artifact (dist/index.js) the QN has no node and Strategy 1 + * misses. Here we map the workspace member NAME → source DIR (cbm_workspaces) + * and probe conventional entry files, so the IMPORTS edge lands on the source. + * The import-targetable-label filter preserves the #767 Folder-phantom guard; + * self-imports are rejected. Returns a borrowed node or NULL. */ +static const cbm_gbuf_node_t *resolve_workspace_member(const cbm_pipeline_ctx_t *ctx, + const char *source_file_qn, + const char *module_path) { + cbm_workspaces_t *ws = cbm_pipeline_get_workspaces(); + if (!ws || !ws->members || !module_path || !module_path[0]) { + return NULL; + } + /* Split module_path into (member-name, subpath): the longest member prefix + * wins. Try the whole path first, then trim trailing '/' segments. */ + char work[PKGMAP_PATH_BUF]; + snprintf(work, sizeof(work), "%s", module_path); + const char *dir = NULL; + const char *subpath = ""; + for (;;) { + const char *hit = (const char *)cbm_ht_get(ws->members, work); + if (hit) { + dir = hit; + subpath = module_path + strlen(work); + if (*subpath == '/') { + subpath++; + } + break; + } + char *slash = strrchr(work, '/'); + if (!slash) { + break; + } + *slash = '\0'; + } + if (!dir) { + return NULL; + } + + /* Candidate relative source paths, in priority order (extension is stripped + * by fqn_module, so no suffix here). */ + char cands[5][PKGMAP_PATH_BUF]; + int ncand = 0; + if (subpath[0]) { + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s/%s", dir, subpath); + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s/src/%s", dir, subpath); + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s/%s/index", dir, subpath); + } else { + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s/src/index", dir); + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s/index", dir); + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s/src/main", dir); + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s/main", dir); + snprintf(cands[ncand++], PKGMAP_PATH_BUF, "%s/lib/index", dir); + } + + for (int i = 0; i < ncand; i++) { + char *qn = cbm_pipeline_fqn_module(ctx->project_name, cands[i]); + if (!qn) { + continue; + } + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(ctx->gbuf, qn); + free(qn); + if (n && import_targetable_label(n->label) && + (!source_file_qn || !n->qualified_name || + strcmp(n->qualified_name, source_file_qn) != 0)) { + return n; + } + } + return NULL; +} + const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t *ctx, const char *source_rel, const char *source_file_qn, @@ -1377,6 +1699,17 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t } } + /* Strategy 1c: npm workspace member resolution (#271). Maps a bare + * `@org/pkg[/subpath]` specifier to the member's real source entry when + * the declared package.json entry points at an un-indexed build artifact. */ + { + const cbm_gbuf_node_t *wsn = + resolve_workspace_member(ctx, source_file_qn, imp->module_path); + if (wsn) { + return wsn; + } + } + /* Strategy 2: namespace map. `using App.Utils`, `import com.example.Foo`, * `use App\Utils\Helper` name a NAMESPACE (or a member of it) that the * path-based QN cannot express. Try the full module path and progressively diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 049cca34a..d52d27882 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -124,6 +124,26 @@ void cbm_pipeline_set_pkgmap(CBMHashTable *map) { g_pkgmap = map; } +/* npm workspace member map, same "one active pipeline at a time" lifecycle. + * Unlike pkgmap (cbm_pkgmap_build returns NULL for a repo with no manifests, so + * the global simply stays NULL), cbm_workspaces_new() always allocates before + * detection runs. The setter therefore OWNS the lifecycle: it frees the + * previous collection when replaced, so callers that re-set without an + * intervening pipeline cleanup (e.g. tests that invoke cbm_parallel_extract + * directly) cannot leak the prior collection. Passing NULL clears + frees. */ +static cbm_workspaces_t *g_workspaces = NULL; + +cbm_workspaces_t *cbm_pipeline_get_workspaces(void) { + return g_workspaces; +} + +void cbm_pipeline_set_workspaces(cbm_workspaces_t *ws) { + if (g_workspaces && g_workspaces != ws) { + cbm_workspaces_free(g_workspaces); + } + g_workspaces = ws; +} + /* ── Timing helper ──────────────────────────────────────────────── */ static double elapsed_ms(struct timespec start) { @@ -737,9 +757,14 @@ static int run_sequential_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, * Use the repo-walking variant so manifests filtered out by the main * discoverer (package.json, composer.json) still feed pkgmap and let * workspace imports like `@my/pkg` resolve to their target Module. */ + /* npm workspaces (#271): detected in the same manifest walk as pkgmap, + * then finalized (candidate → member map) before the resolve passes run. */ + cbm_workspaces_t *ws = cbm_workspaces_new(); cbm_pipeline_set_pkgmap(cbm_pkgmap_build_from_repo(ctx->repo_path, files, file_count, ctx->project_name, ctx->excluded_dirs, - ctx->excluded_count)); + ctx->excluded_count, ws)); + cbm_workspaces_finalize(ws); + cbm_pipeline_set_workspaces(ws); CBMFileResult **seq_cache = (CBMFileResult **)calloc(file_count, sizeof(CBMFileResult *)); if (seq_cache) { @@ -1297,6 +1322,7 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { cleanup: cbm_pkgmap_free(cbm_pipeline_get_pkgmap()); cbm_pipeline_set_pkgmap(NULL); + cbm_pipeline_set_workspaces(NULL); /* guarded setter frees the current collection */ cbm_discover_free(files, file_count); cbm_gbuf_free(p->gbuf); p->gbuf = NULL; diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index a3418e52b..3010ab697 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -153,12 +153,77 @@ bool cbm_pkgmap_try_parse(const char *basename, const char *rel_path, const char CBMHashTable *cbm_pkgmap_build(cbm_pkg_entries_t *worker_entries, int worker_count, const char *project_name); -/* Build pkgmap by reading manifest files from the files array (sequential path). */ +/* ── npm workspaces (#271, Phase 1) ────────────────────────────────── + * Resolve bare cross-package imports in a Yarn/Lerna/npm monorepo where a + * package is referenced by its declared `name` (e.g. `@org/a`) but its + * package.json `main`/`exports` points at a build artifact (dist/…) that is + * never indexed. The pkgmap maps the name → that (dead) entry QN, so the + * ordinary pkgmap lookup misses. We additionally map the workspace member + * NAME → its source DIRECTORY and, at import-resolution time, probe for a + * real in-graph entry file. Detection piggybacks on the existing pkgmap + * manifest walk (no extra file IO). Phase 1 covers package.json `workspaces` + * only (array and {"packages":[…]} forms; '!' negation via the gitignore + * engine). Brace {a,b} expansion is out of scope (debug-logged). */ +enum { + CBM_WS_MAX_ROOTS = 16, /* nested workspace roots per repo */ + CBM_WS_MAX_MEMBERS = 4096, /* member candidates per repo */ +}; + +/* A workspace-member manifest: declared name and its directory. */ +typedef struct { + char *name; /* heap: "@org/a" */ + char *dir; /* heap: dir of the member's package.json ("packages/a") */ +} cbm_ws_candidate_t; + +/* A workspace root: the dir of a package.json that declares `workspaces`, + * plus the member globs compiled into the reused gitignore engine. */ +typedef struct { + char *root_dir; /* heap: dir of the root manifest ("" = repo root) */ + cbm_gitignore_t *patterns; /* owned: compiled `workspaces` globs */ +} cbm_ws_root_t; + +/* Repo-wide workspace state. Candidates and roots are collected during the + * walk (any order), then resolved into `members` by cbm_workspaces_finalize. */ +typedef struct { + cbm_ws_root_t roots[CBM_WS_MAX_ROOTS]; + int root_count; + cbm_ws_candidate_t *cands; + int cand_count; + int cand_cap; + CBMHashTable *members; /* finalize output: name → dir (both heap); NULL before finalize */ +} cbm_workspaces_t; + +/* Allocate an empty workspace collection (NULL on OOM). */ +cbm_workspaces_t *cbm_workspaces_new(void); + +/* Free a workspace collection and all owned strings. NULL-safe. */ +void cbm_workspaces_free(cbm_workspaces_t *ws); + +/* Inspect one manifest during the pkgmap walk. When basename is + * "package.json", records a workspace root (if it declares `workspaces`) and/or + * a member candidate (if it declares `name`). NULL-safe on ws (returns false). + * Returns true iff a workspace root was recorded for this manifest. */ +bool cbm_workspace_try_detect(const char *basename, const char *rel_path, const char *source, + int source_len, cbm_workspaces_t *ws); + +/* Resolve collected candidates against roots into the `members` map + * (name → dir). Idempotent and NULL-safe. */ +void cbm_workspaces_finalize(cbm_workspaces_t *ws); + +/* Current pipeline's workspace collection (one active pipeline at a time, + * mirrors the pkgmap global). May be NULL. */ +cbm_workspaces_t *cbm_pipeline_get_workspaces(void); +void cbm_pipeline_set_workspaces(cbm_workspaces_t *ws); + +/* Build pkgmap by reading manifest files from the files array (sequential path). + * `ws` (may be NULL) additionally receives npm-workspace roots/candidates + * detected in the same walk, so no manifest is read twice. */ int cbm_pkgmap_scan_repo(const char *repo_path, cbm_pkg_entries_t *entries, char **excluded_dirs, - int excluded_count); + int excluded_count, cbm_workspaces_t *ws); CBMHashTable *cbm_pkgmap_build_from_repo(const char *repo_path, const cbm_file_info_t *files, int file_count, const char *project_name, - char **excluded_dirs, int excluded_count); + char **excluded_dirs, int excluded_count, + cbm_workspaces_t *ws); CBMHashTable *cbm_pkgmap_build_from_files(const cbm_file_info_t *files, int file_count, const char *project_name); diff --git a/tests/test_lang_contract.c b/tests/test_lang_contract.c index f9f91066f..c4dca4b21 100644 --- a/tests/test_lang_contract.c +++ b/tests/test_lang_contract.c @@ -1158,6 +1158,45 @@ TEST(contract_edge_workspaces_imports_issue408) { PASS(); } +/* #271: npm `workspaces` where the member's package.json entry point (`main`) + * targets an UNBUILT artifact (dist/index.js) that is never indexed. The pkgmap + * still maps "@org/a" -> the dist entry QN, but no such node exists, so the + * pkgmap exact-lookup (Strategy 1) misses and today no IMPORTS edge is produced. + * The fix maps the workspace member NAME -> source DIR and probes conventional + * source entry files (src/index, ...). RED until workspace member resolution + * lands. (Distinct from #408, whose fixture points `main` at a real index.js.) */ +TEST(contract_edge_workspaces_dist_main_imports_issue271) { + static const LangFile f[] = { + {"package.json", "{\"name\":\"root\",\"private\":true,\"workspaces\":[\"packages/*\"]}\n"}, + {"packages/a/package.json", + "{\"name\":\"@org/a\",\"version\":\"1.0.0\",\"main\":\"dist/index.js\"}\n"}, + {"packages/a/src/index.ts", "export function fromA(): number {\n return 1;\n}\n"}, + {"packages/b/package.json", + "{\"name\":\"@org/b\",\"version\":\"1.0.0\",\"main\":\"dist/index.js\"}\n"}, + {"packages/b/src/index.ts", "import { fromA } from '@org/a';\n\nexport function useA(): " + "number {\n return fromA();\n}\n"}}; + ASSERT_TRUE(edge_present(f, 5, "IMPORTS", 1)); + PASS(); +} + +/* #271: subpath workspace import `@org/a/utils/helper`. The pkgmap slash-prefix + * fallback concatenates the (wrong) dist entry QN with the subpath, so no node + * matches. Workspace member resolution must decompose (member, subpath) and + * probe `