diff --git a/.agents/docs/2026-06-30-workspace-test-and-zero-shell-index-design.md b/.agents/docs/2026-06-30-workspace-test-and-zero-shell-index-design.md new file mode 100644 index 0000000..e78ab3b --- /dev/null +++ b/.agents/docs/2026-06-30-workspace-test-and-zero-shell-index-design.md @@ -0,0 +1,241 @@ +# Workspace-aware `mcpp test` + zero-shell self-contained mcpp-index (Design) + +Two coupled deliverables toward "把对库的测试做成真实工程、基于 mcpp 自包含、消除所有 +shell": + +- **Phase 1 (repo `mcpp`)** — make `mcpp test` workspace-aware: `mcpp test -p `, + `mcpp build|test --workspace`, and fix bare `mcpp test` at a workspace root. +- **Phase 2 (repo `mcpp-index`)** — turn each `tests//` into a real mcpp test + project (`mcpp test` + behavioral assertions), migrate the `smoke_compat_*.sh` + heredocs into them, and delete every shell driver — CI becomes `mcpp test --workspace`. + +Pairs with the typed `import mcpp;` build library +(`2026-06-30-l3-build-mcpp-implementation-design.md` §forward-note, task #20) as the +**0.0.79 "workspace-test + build.mcpp library"** release. + +--- + +## 1. Problem (grounded in the code) + +`mcpp build` is workspace-aware; `mcpp test` is not. + +- `mcpp build -p ` works (`src/cli.cppm` build subcommand has + `.option("package").short_name('p')`; `cmd_build` copies it to + `BuildOverrides::package_filter` — `src/cli/cmd_build.cppm`; `prepare_build` + resolves the member and **reassigns `root` to the member dir** — + `src/build/prepare.cppm:415-509`). +- `mcpp test` has **no** `-p` / `--workspace` (`src/cli.cppm` test subcommand). +- **The bug:** `run_tests` (`src/build/execute.cppm:421-459`) calls + `find_manifest_root()` → gets the **workspace** root → `expand_glob(*root, + "tests/**/*.cpp")` → collects *every member's* `tests/.../main.cpp` → + `seenNames.insert("main")` collides → `error: duplicate test name 'main'`. Test + discovery runs **before** any member selection, on the unscoped workspace root. + +Proof: +``` +$ mcpp build # virtual ws root → "Workspace building member 'tests/examples/build-mcpp'" (picks ONE, arbitrarily) +$ mcpp test # virtual ws root → error: duplicate test name 'main' (globs ALL, unscoped) +``` + +## 2. Architecture principle + +**Scope, don't duplicate.** The root-test bug is *missing member scoping*, not a +missing second globber. The fix is to run the **same** member resolution `build` +uses *before* test discovery, so `tests/**/*.cpp` is globbed from the **member** +dir. `--workspace` is **thin orchestration**: a loop over members that calls the +**existing per-member pipeline** once each — no parallel build/test path. + +Concretely, extract today's inline member logic into one shared helper and have +both `build` and `test` consume it: + +```cpp +// src/build/workspace.cppm (new, or fold into project.cppm) +namespace mcpp::build { + struct MemberRef { std::string name; std::filesystem::path dir; }; + // Resolve which members a command acts on, from the (already-loaded) root + // manifest + overrides. Encapsulates the selection rules in §3. + std::expected, std::string> + select_members(const manifest::Manifest& root, const std::filesystem::path& rootDir, + const BuildOverrides& ov, bool wantAll); +} +``` + +- `-p ` → 1 member (match by directory basename **or** member path — the + rule already in `prepare.cppm:430-447`). +- `--workspace` (or bare at a **virtual** workspace) → **all** members. +- Bare at a **rooted** workspace (`[package]` + `[workspace]`) → the root package + (members only via `--workspace`), matching today. + +`prepare_build`'s existing per-member switch (load member manifest, `merge_workspace_deps`, +inherit toolchain/target/indices, `root = memberDir`) stays as the **single-member** +mechanism; `select_members` just decides the *set*, and the orchestration loop calls +`prepare_build(package_filter = member.name)` per member. + +## 3. Behavior matrix (decision) + +| invocation | virtual workspace | rooted workspace | plain package | +|---|---|---|---| +| `mcpp build` / `mcpp test` | **all members** | root package | the package | +| `... -p ` | member `` | member `` | error (no members) | +| `... --workspace` | all members | all members (+root) | error | +| `mcpp run` (+`-p`) | default/`-p` member | root/`-p` | the package | + +**Change from today:** bare build/test at a *virtual* workspace goes from "pick one +arbitrary member" → **all members** (Cargo-consistent; the pick-one was a +placeholder). `run` stays single-target (no `--workspace`). This is the only +behavior change; verify/adjust the workspace e2e (`tests/e2e/*workspace*`). + +## 4. Phase 1 — implementation touch points + +1. **CLI** (`src/cli.cppm`, test subcommand ~L244-256): add + ```cpp + .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") + .help("Run tests only for the named workspace member")) + .option(cl::Option("workspace").help("Run tests for all workspace members")) + ``` + and add `.option("workspace")` to the **build** subcommand (~L215-237). +2. **Parse** (`src/cli/cmd_build.cppm`): in `cmd_test`, copy `-p` → + `ov.package_filter`; read `--workspace` → `bool all`. Same `--workspace` read in + `cmd_build`. +3. **`select_members` helper** (new `src/build/workspace.cppm`): the §2 logic, + extracted from `prepare.cppm:422-453` so both paths share it. +4. **`run_tests` scoping** (`src/build/execute.cppm:421-459`): before the + `expand_glob`, resolve the member root for the single-member case (when + `package_filter` set, via the shared helper) and glob from the **member dir**, + not the workspace root. This kills the "duplicate main" bug by *scoping*. +5. **`--workspace` orchestration** (`src/cli/cmd_build.cppm`): when `all`, call + `select_members(..., wantAll=true)` and loop — `cmd_build` runs + `prepare_build`+`run_build_plan` per member; `cmd_test` runs `run_tests` per + member (each with `package_filter = member.name`). Aggregate exit codes + (first non-zero wins; print a per-member summary). Continue-on-failure so one + member's failure still reports the rest. +6. **Tests** (`tests/e2e/90_workspace_test.sh`): a virtual workspace with 2 members + each having `tests/.cpp` asserting behavior; assert + `mcpp test -p memberA` runs only A's tests, `mcpp test --workspace` runs both + (no duplicate-stem error), bare `mcpp test` at the root runs both. +7. **Docs** (`docs/06-workspace.md` + zh): document `-p`/`--workspace` for + build/test and the bare-at-root semantics. + +## 5. Phase 2 — mcpp-index zero-shell restructure (repo `mcpp-index`) + +### Target layout +``` +mcpp-index/ + pkgs/ # the index (recipes) — unchanged + mcpp.toml # [workspace] members=tests/* ⊕ [indices] compat={path="."} + tests/ + cjson/ + mcpp.toml # [package] cjson-tests; [dependencies] compat.cjson + tests/parse.cpp # mcpp test — assert cJSON_Parse fields + eigen/ tests/matmul.cpp # assert A*B values + nlohmann.json/ tests/roundtrip.cpp # parse→dump→parse equality + openblas/ # [target.'cfg(windows)'.dependencies] openblas; tests/dgemm.cpp asserts [19 22;43 50] (no-op gate off-Windows) + build-mcpp/ # build.mcpp generates a source; tests/ assert it linked +``` + +### Test mechanism +mcpp's native `mcpp test` discovers `tests/**/*.cpp`, builds each as a test binary, +runs it (non-zero = fail). Members use plain assertion `.cpp` (a failing `assert`/ +non-zero return), no external framework required — keeps members dependency-light +and host-portable. (gtest remains available via `[dev-dependencies]` if a member +wants richer output, but is not required.) + +### Migration (delete all shell) +- Convert each `smoke_compat_*.sh` heredoc body into the matching member's + `tests/*.cpp` (the heredocs already contain the usage snippets). +- **Delete** `tests/smoke_compat_{core,imgui,archive,imgui_window}.sh`, + `tests/smoke_imgui_module.sh`, `tests/smoke_compat_portable.sh`, + `tests/run_workspace.sh`, `tests/run_example.sh`. +- **`validate.yml`** collapses: the per-suite jobs become one matrix or a single + `mcpp test --workspace` step: + ```yaml + - run: mcpp test --workspace # linux; the whole index, self-contained + ``` + Windows/macOS jobs run the same command (platform-gated members like openblas + self-gate via `cfg(windows)`; the `detect` job can still narrow to `-p ` on + PRs touching one recipe). No shell driver remains. + +### Result +The index repo is simultaneously (a) the package index and (b) a mcpp workspace +whose members really *use and test* every recipe — driven entirely by `mcpp`, no +`.sh`. "基于 mcpp 自包含" achieved. + +### Findings during Phase 2 (recorded) +- **build.mcpp cwd bug** — fixed in 0.0.79 (see §6.1); the `build-mcpp` member + drove it out (its `build.mcpp` wrote to the wrong dir under `-p`). +- **Pure test projects work** — a member with only `[dependencies]` + `tests/*.cpp` + (no `src/`) builds + tests cleanly; the dep's headers/lib reach the test binary. +- **Feature-built dependency objects don't link into test binaries** — the eigen + `eigen_blas` feature compiles Eigen's reference BLAS into compat.eigen's lib, but + the test binary calling `dgemm_` fails to link it (worked when the member was a + `bin`). The eigen member tests header-only Eigen for now; linking feature-gated + dependency objects into `mcpp test` binaries is a separate mcpp follow-up. +- **Display/GL smokes stay shell for now** — `smoke_compat_{imgui,imgui_window, + glfw}` + the portable matrix need a display / broader libs; migrating them to + headless `mcpp test` members is a later increment. Phase 2 converts the 5 + headless example members + switches their CI to `mcpp test`. + +## 6. Typed `import mcpp;` library (task #20) — DEFERRED (own follow-up) + +A typed module **bundled in the mcpp binary**, emitting the existing stdout +`mcpp:` wire protocol, implemented with C-level I/O so neither it nor `build.mcpp` +needs `import std;`. **De-risking confirmed the module itself works** (GCC 16): + +```cpp +module; #include +export module mcpp; +export namespace mcpp { + inline void cxxflag(const char* f) { std::printf("mcpp:cxxflag=%s\n", f); } + inline void link_lib(const char* n) { std::printf("mcpp:link-lib=%s\n", n); } + // ... +} +``` +`g++ -std=c++23 --sysroot=… -fmodules -c mcpp.cppm -o mcpp.o` → `gcm.cache/mcpp.gcm` ++ `mcpp.o`; then `g++ … -fmodules -x c++ build.mcpp -x none mcpp.o -o bin` (run +**from the dir containing `gcm.cache/`**) → `import mcpp;` resolves; the binary +emits the directives. No `import std;` needed. + +**Why deferred (not in 0.0.79):** one remaining piece this release won't rush: +1. ~~**cwd-capable spawn.**~~ **DONE in 0.0.79** — `capture_exec` gained a `cwd` + parameter (Linux `posix_spawn_file_actions_addchdir_np`; else `cd … &&`), added + for the build.mcpp-cwd correctness fix. This is exactly what the typed lib needs + to stage `gcm.cache/.gcm` (GCC C++ finds modules only relative to the compile + CWD — the named `-fmodule-file=mcpp=` form is rejected "valid for D not + C++"; `-fmodule-output=` is absent on GCC 16). So the GCC path is now unblocked. +2. **Clang path.** Clang uses `.pcm` + `--precompile` + `-fmodule-file=mcpp=` + (different ABI/flags), untested here. Without it, `build.mcpp` using + `import mcpp;` on a Clang host (macOS/Windows) would fail to compile — a partial + feature. Both compiler paths must land together → still a focused 0.0.80. + +Plus: embed the module source in the binary, compile it **once** into +`target/.build-mcpp/` keyed on the toolchain (cache; don't rebuild), then convert +the build.mcpp docs/examples/test + the mcpp-index `build-mcpp` member to +`import mcpp;`. Tracked as task #20 for a focused 0.0.80. The string-protocol +substrate already ships `build.mcpp` today, so this is a pure ergonomic layer — no +functionality is blocked by deferring it. + +## 7. Sequencing & releases + +1. **mcpp PR A** — Phase 1 (workspace-aware test) + e2e + docs → **release 0.0.79** + → full ecosystem loop (mirror → index → verify → pin). +2. **mcpp-index PR B** — Phase 2 restructure on 0.0.79; delete all shell; CI = + `mcpp test --workspace` → its CI green → merge. (Primary user goal: zero-shell, + self-contained, `-p`-addressable index.) +3. **(follow-up) mcpp 0.0.80** — typed `import mcpp;` library (§6), with the + cwd-capable spawn + Clang `.pcm` path; then convert build.mcpp docs/examples and + the mcpp-index `build-mcpp` member to `import mcpp;`. + +## 8. Risks / soundness notes + +- **Behavior change** (bare virtual-ws build/test → all members): the one + compatibility-affecting change; covered by updating the workspace e2e and is the + more-correct semantics. `run` is untouched (single-target). +- **No parallel code path**: `--workspace`/`-p` are orchestration over the proven + per-member `prepare_build`/`run_tests`; the helper centralizes selection so the + rules can't drift between `build` and `test` (the class of bug we're fixing). +- **Aggregate reporting**: `--workspace` must continue-on-failure and print a + per-member pass/fail summary, else one red member hides the rest. +- **Per-member dep isolation** is the reason for the workspace (vs one mega + `[package]`): members resolve independently, so conflicting transitive deps and + platform-only libs (openblas/Windows) don't couple. Recorded as the rejected + single-project alternative. diff --git a/docs/06-workspace.md b/docs/06-workspace.md index be6e879..8de2da7 100644 --- a/docs/06-workspace.md +++ b/docs/06-workspace.md @@ -153,14 +153,24 @@ default = "clang@19.0" ## 5. Build Commands -### 5.1 Building from the Workspace Root +### 5.1 Building & testing from the Workspace Root ```bash -mcpp build # build the default target (auto-selects the member with a binary target) +mcpp build # virtual workspace → builds ALL members; rooted → the root package mcpp build -p server # build a specific member and its dependencies -mcpp build -p core # build a specific library member +mcpp build --workspace # build every member explicitly +mcpp test # virtual workspace → tests ALL members; rooted → the root package +mcpp test -p core # test a single member +mcpp test --workspace # test every member (one report per member; continues past failures) ``` +At a **virtual** workspace root (only `[workspace]`, no `[package]`), bare +`mcpp build` / `mcpp test` act on **all** members. At a **rooted** workspace +(`[package]` + `[workspace]`), they act on the root package; use `--workspace` to +include all members. `mcpp test --workspace` builds + runs each member's +`tests/**/*.cpp` independently — discovery is scoped per member, so two members may +each have a `tests/main.cpp` without colliding. + ### 5.2 Building from a Member Subdirectory ```bash @@ -180,6 +190,11 @@ mcpp test -p core # matches libs/core mcpp run -p server -- --port 8080 ``` +`--workspace` (on `build` and `test`) is the fan-out form: it acts on **every** +member. `mcpp test --workspace` reports each member separately and continues past a +failing member, exiting non-zero if any member failed — ideal as a single, +shell-free CI step for a workspace that tests many libraries. + ## 6. Directory Layout The recommended directory layout for a workspace: diff --git a/docs/zh/06-workspace.md b/docs/zh/06-workspace.md index 8c0a452..9c79cc8 100644 --- a/docs/zh/06-workspace.md +++ b/docs/zh/06-workspace.md @@ -153,14 +153,22 @@ default = "clang@19.0" ## 5. 构建命令 -### 5.1 从工作空间根目录构建 +### 5.1 从工作空间根目录构建与测试 ```bash -mcpp build # 构建默认目标(自动选择含二进制目标的成员) +mcpp build # 虚拟工作空间 → 构建所有成员;带根包 → 构建根包 mcpp build -p server # 构建指定成员及其依赖 -mcpp build -p core # 构建指定库成员 +mcpp build --workspace # 显式构建每个成员 +mcpp test # 虚拟工作空间 → 测试所有成员;带根包 → 测试根包 +mcpp test -p core # 测试单个成员 +mcpp test --workspace # 测试每个成员(逐成员汇报;遇失败继续) ``` +在**虚拟工作空间**根(只有 `[workspace]`、无 `[package]`)下,裸 `mcpp build` / +`mcpp test` 作用于**所有**成员;在**带根包工作空间**(`[package]` + `[workspace]`)下作用于 +根包,用 `--workspace` 纳入全部成员。`mcpp test --workspace` 独立构建+运行每个成员的 +`tests/**/*.cpp`——测试发现按成员隔离,因此两个成员各有一个 `tests/main.cpp` 也不会冲突。 + ### 5.2 从成员子目录构建 ```bash @@ -180,6 +188,10 @@ mcpp test -p core # 匹配 libs/core mcpp run -p server -- --port 8080 ``` +`--workspace`(用于 `build` 和 `test`)是扇出形式:作用于**每个**成员。 +`mcpp test --workspace` 逐成员独立汇报、遇失败继续,只要有任一成员失败即非零退出—— +非常适合作为「一个测试众多库的工作空间」的单条、无 shell 的 CI 步骤。 + ## 6. 目录布局 工作空间推荐的目录布局: diff --git a/mcpp.toml b/mcpp.toml index bfc40cd..8f6a64a 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.78" +version = "0.0.79" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index e36824e..644a84d 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -299,15 +299,17 @@ std::expected run_build_program( compileArgv.push_back(src.string()); compileArgv.push_back("-o"); compileArgv.push_back(bin.string()); mcpp::ui::info("build.mcpp", "compiling"); - auto cres = mcpp::platform::process::capture_exec(compileArgv); + auto cres = mcpp::platform::process::capture_exec(compileArgv, {}, root.string()); if (cres.exit_code != 0) { return std::unexpected(std::format( "build.mcpp failed to compile (exit {}):\n{}", cres.exit_code, cres.output)); } // ── Run it; capture stdout(+stderr) and parse directives ──────────────── + // Run with cwd = project root so the program's relative file writes (e.g. + // mcpp:generated sources) land in the project, not in mcpp's invocation dir. mcpp::ui::info("build.mcpp", "running"); - auto rres = mcpp::platform::process::capture_exec({bin.string()}); + auto rres = mcpp::platform::process::capture_exec({bin.string()}, {}, root.string()); if (rres.exit_code != 0) { return std::unexpected(std::format( "build.mcpp exited with {} (build aborted):\n{}", rres.exit_code, rres.output)); diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 70e7fa1..cafa474 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -426,8 +426,21 @@ export int run_tests(std::span passthrough, return 2; } - // 1. Discover test files. - auto testFiles = mcpp::modgraph::expand_glob(*root, "tests/**/*.cpp"); + // Workspace scoping: discovery must run against the MEMBER, not the + // workspace root — otherwise `tests/**/*.cpp` globs every member's tests + // together (two `tests/main.cpp` → "duplicate test name 'main'"). When a + // member is selected (via -p, threaded as package_filter), glob from its + // dir; prepare_build below resolves the SAME member, so the two agree. + // (--workspace fans out over members at the cmd layer, one call per member.) + auto testRoot = *root; + if (auto rm = mcpp::manifest::load(*root / "mcpp.toml"); rm) { + auto member = mcpp::project::resolve_member_dir(*rm, *root, overrides.package_filter); + if (!member) { mcpp::ui::error(member.error()); return 2; } + if (!member->empty()) testRoot = *member; + } + + // 1. Discover test files (scoped to the member/package). + auto testFiles = mcpp::modgraph::expand_glob(testRoot, "tests/**/*.cpp"); if (testFiles.empty()) { std::println("no tests found in tests/"); return 0; @@ -447,8 +460,8 @@ export int run_tests(std::span passthrough, mcpp::manifest::Target t; t.name = name; t.kind = mcpp::manifest::Target::TestBinary; - // Store as path relative to project root for portability of error messages. - t.main = std::filesystem::relative(f, *root).string(); + // Relative to the member/package root prepare_build will operate on. + t.main = std::filesystem::relative(f, testRoot).string(); testTargets.push_back(std::move(t)); } diff --git a/src/cli.cppm b/src/cli.cppm index f9e5d9e..ac7b655 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -234,6 +234,8 @@ int run(int argc, char** argv) { .help("Pin capability providers (e.g. blas=openblas,lapack=mkl)")) .option(cl::Option("strict") .help("Treat manifest schema warnings (unknown feature/platform) as errors")) + .option(cl::Option("workspace") + .help("Build all workspace members")) .action(wrap_rc(cmd_build))) .subcommand(cl::App("run") .description("Build + run a binary target (after `--`, args are passed to it)") @@ -251,6 +253,10 @@ int run(int argc, char** argv) { .help("Pin capability providers (e.g. blas=openblas,lapack=mkl)")) .option(cl::Option("strict") .help("Treat manifest schema warnings (unknown feature/platform) as errors")) + .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") + .help("Run tests only for the named workspace member")) + .option(cl::Option("workspace") + .help("Run tests for all workspace members")) .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { return cmd_test(p, std::span(passthrough)); }))) diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 0291e56..7589308 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -15,9 +15,28 @@ import mcpp.build.execute; import mcpp.dyndep; import mcpp.log; import mcpp.project; +import mcpp.manifest; +import mcpp.ui; namespace mcpp::cli { +// Decide whether a build/test invocation fans out over workspace members, and +// if so which. Fan out when `--workspace` is given, or at a *virtual* workspace +// root with no `-p` (the intuitive "act on the whole workspace"). Returns the +// member paths to iterate, or nullopt for the single-package / single-`-p` / +// rooted-bare path (handled by the existing per-package pipeline). +std::optional> +workspace_fanout_members(bool wantAll, const std::string& package_filter) { + auto root = mcpp::project::find_manifest_root(std::filesystem::current_path()); + if (!root) return std::nullopt; + auto m = mcpp::manifest::load(*root / "mcpp.toml"); + if (!m || !m->workspace.present || m->workspace.members.empty()) return std::nullopt; + bool virtualWs = m->package.name.empty(); + if (wantAll || (virtualWs && package_filter.empty())) + return m->workspace.members; + return std::nullopt; +} + export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) { bool verbose = parsed.is_flag_set("verbose") || mcpp::log::is_verbose(); bool print_fp = parsed.is_flag_set("print-fingerprint"); @@ -37,6 +56,24 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) { ov.strict = parsed.is_flag_set("strict"); ov.force_static = parsed.is_flag_set("static"); + // Workspace fan-out: build every member, one per the existing per-package + // pipeline (continue-on-failure; first non-zero exit wins). Checked before + // the fast path, which is single-package only. + if (auto members = workspace_fanout_members(parsed.is_flag_set("workspace"), + ov.package_filter)) { + int rc = 0; + for (auto& mp : *members) { + mcpp::build::BuildOverrides mo = ov; + mo.package_filter = mp; + auto ctx = mcpp::build::prepare_build(print_fp, /*includeDevDeps=*/false, + /*extraTargets=*/{}, mo); + if (!ctx) { std::println(stderr, "error: {}: {}", mp, ctx.error()); rc = 2; continue; } + int r = mcpp::build::run_build_plan(*ctx, verbose, no_cache, mo.target_triple); + if (r != 0) rc = r; + } + return rc; + } + // P0: try fast-path if inputs haven't changed. Any resolution-affecting // override (--profile/--features/--strict, like --target/--static) must // bypass it: the cached build.ninja was generated without them, so taking @@ -81,6 +118,32 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, if (auto fs = parsed.value("features")) ov.features = *fs; if (auto cp = parsed.value("cap")) ov.capabilities = *cp; ov.strict = parsed.is_flag_set("strict"); + if (auto p = parsed.value("package")) ov.package_filter = *p; + + // Workspace fan-out: test every member through run_tests (which scopes its + // discovery to the member). Continue-on-failure + per-member summary so one + // red member never hides the rest. + if (auto members = workspace_fanout_members(parsed.is_flag_set("workspace"), + ov.package_filter)) { + int rc = 0; + std::vector failed; + for (auto& mp : *members) { + mcpp::build::BuildOverrides mo = ov; + mo.package_filter = mp; + mcpp::ui::status("Workspace", std::format("testing member '{}'", mp)); + int r = mcpp::build::run_tests(passthrough, mo); + if (r != 0) { rc = r; failed.push_back(mp); } + } + if (failed.empty()) + mcpp::ui::status("Workspace", + std::format("all {} member(s) passed", members->size())); + else + mcpp::ui::error(std::format("workspace test: {}/{} member(s) failed: {}", + failed.size(), members->size(), [&]{ + std::string s; for (auto& f : failed) { if (!s.empty()) s += ", "; s += f; } + return s; }())); + return rc; + } return mcpp::build::run_tests(passthrough, ov); } diff --git a/src/platform/process.cppm b/src/platform/process.cppm index f5bf924..8906c25 100644 --- a/src/platform/process.cppm +++ b/src/platform/process.cppm @@ -19,6 +19,9 @@ // Callers are responsible for shell-quoting arguments (see platform.shell). module; +#ifndef _GNU_SOURCE +#define _GNU_SOURCE // for posix_spawn_file_actions_addchdir_np (glibc) +#endif #include #include #if defined(_WIN32) @@ -77,7 +80,8 @@ int run_exec(const std::vector& argv, // is_stale_ninja_failure / filter_ninja_output. No shell → no quoting/injection. RunResult capture_exec( const std::vector& argv, - const std::vector>& extraEnv = {}); + const std::vector>& extraEnv = {}, + std::string_view cwd = {}); // Run `command` silently (discard stdout/stderr). // On POSIX, stdin is automatically redirected from /dev/null. @@ -325,7 +329,8 @@ int run_exec(const std::vector& argv, RunResult capture_exec( const std::vector& argv, - const std::vector>& extraEnv) + const std::vector>& extraEnv, + std::string_view cwd) { RunResult result; if (argv.empty()) { result.exit_code = 127; return result; } @@ -345,6 +350,11 @@ RunResult capture_exec( posix_spawn_file_actions_t fa; ::posix_spawn_file_actions_init(&fa); + // Run the child in `cwd` when requested (e.g. build.mcpp, whose relative + // file writes must land in the project root regardless of mcpp's own cwd). + std::string cwdStore(cwd); + if (!cwdStore.empty()) + ::posix_spawn_file_actions_addchdir_np(&fa, cwdStore.c_str()); ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); // stdout → pipe ::posix_spawn_file_actions_adddup2(&fa, fds[1], 2); // stderr → same pipe ::posix_spawn_file_actions_addclose(&fa, fds[0]); @@ -367,6 +377,8 @@ RunResult capture_exec( return result; #else std::string cmd = command_from_argv(argv) + " 2>&1"; + if (!cwd.empty()) + cmd = "cd " + mcpp::platform::shell::quote(cwd) + " && " + cmd; return capture_with_env(cmd, extraEnv); #endif } diff --git a/src/project.cppm b/src/project.cppm index 64229e0..cad11b6 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -74,4 +74,35 @@ export void merge_workspace_deps(mcpp::manifest::Manifest& member, merge_map(member.buildDependencies); } +// Resolve which member directory a workspace command acts on, for the +// single-member case. Shares the match rule (basename OR member path) with +// prepare_build's member switch, so `build -p X` and `test -p X` agree. +// Returns: +// - the member dir when `package_filter` names a member, +// - empty path when no switch applies (not a workspace, or a rooted +// workspace with no filter → act on the root package), +// - error when the filter names an unknown member, or a *virtual* +// workspace is addressed with no filter (the caller must +// pick a member with -p or fan out with --workspace). +export std::expected +resolve_member_dir(const mcpp::manifest::Manifest& rootManifest, + const std::filesystem::path& rootDir, + std::string_view package_filter) { + if (!rootManifest.workspace.present) return std::filesystem::path{}; + if (!package_filter.empty()) { + for (auto& mp : rootManifest.workspace.members) { + auto basename = std::filesystem::path(mp).filename().string(); + if (basename == package_filter || mp == package_filter) + return rootDir / mp; + } + return std::unexpected(std::format( + "workspace member '{}' not found in [workspace].members", package_filter)); + } + if (rootManifest.package.name.empty()) { + return std::unexpected(std::string( + "virtual workspace: specify -p or --workspace")); + } + return std::filesystem::path{}; // rooted workspace, no filter → root package +} + } // namespace mcpp::project diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 50847db..8af248c 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "0.0.78"; +inline constexpr std::string_view MCPP_VERSION = "0.0.79"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/89_build_mcpp.sh b/tests/e2e/89_build_mcpp.sh index 50736ee..fb1b9ca 100755 --- a/tests/e2e/89_build_mcpp.sh +++ b/tests/e2e/89_build_mcpp.sh @@ -67,4 +67,36 @@ touch src/main.cpp MCPP_TEST_TOGGLE=1 "$MCPP" build > b3.log 2>&1 || { cat b3.log; echo "FAIL: build 3 errored"; exit 1; } grep -qi "build.mcpp.*\(running\|compiling\)" b3.log || { cat b3.log; echo "FAIL: changed env did not force build.mcpp re-run"; exit 1; } +# ── CWD independence: build.mcpp must run with cwd = project root, so its +# relative file writes (the generated source) land in the project even when +# mcpp is invoked from a SUBDIRECTORY (e.g. workspace -p, or `cd src && mcpp`). +cd "$TMP" +mkdir -p sub/src +cat > sub/mcpp.toml <<'EOF' +[package] +name = "sub" +version = "0.1.0" +EOF +cat > sub/src/main.cpp <<'EOF' +#ifndef FROM_BUILD_MCPP +#error "define missing" +#endif +int gen(); +int main() { return gen() == 7 ? 0 : 1; } +EOF +cat > sub/build.mcpp <<'EOF' +#include +#include +int main() { + std::ofstream("src/gen.cpp") << "int gen(){return 7;}\n"; + std::puts("mcpp:generated=src/gen.cpp"); + std::puts("mcpp:cxxflag=-DFROM_BUILD_MCPP=1"); + return 0; +} +EOF +# Invoke from the nested src/ dir — mcpp walks up to the project; build.mcpp must +# still write src/gen.cpp into the project root, not into the cwd. +( cd sub/src && "$MCPP" build > "$TMP/sub.log" 2>&1 ) || { cat "$TMP/sub.log"; echo "FAIL: build from subdir errored (cwd not project root?)"; exit 1; } +[ -f sub/src/gen.cpp ] || { echo "FAIL: generated source not written to project root"; exit 1; } + echo "OK" diff --git a/tests/e2e/90_workspace_test.sh b/tests/e2e/90_workspace_test.sh new file mode 100755 index 0000000..e4329eb --- /dev/null +++ b/tests/e2e/90_workspace_test.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# 90_workspace_test.sh — workspace-aware `mcpp test`: `-p `, +# `--workspace`, and bare `mcpp test` at a virtual workspace root. The headline +# fix: discovery is scoped to the selected member, so two members may each have a +# `tests/main.cpp` (same stem) without the old "duplicate test name 'main'" error. +# See .agents/docs/2026-06-30-workspace-test-and-zero-shell-index-design.md. +# +# requires: gcc +set -euo pipefail + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +mkdir -p liba/src liba/tests libb/src libb/tests + +cat > mcpp.toml <<'EOF' +[workspace] +members = ["liba", "libb"] +EOF + +# Each member is a library (so its own source doesn't collide with tests/main.cpp); +# the test binary is the standalone tests/main.cpp. +for L in liba libb; do +cat > "$L/mcpp.toml" < "$L/src/$L.cppm" +done + +# Both members name their test the SAME stem ('main') — this is exactly what used +# to collide at a workspace root. A real (passing) assertion in each. +cat > liba/tests/main.cpp <<'EOF' +int main() { return (1 + 1 == 2) ? 0 : 1; } +EOF +cat > libb/tests/main.cpp <<'EOF' +int main() { return (2 * 2 == 4) ? 0 : 1; } +EOF + +# 1. -p selects one member; its test runs, the other does not. +out=$("$MCPP" test -p liba 2>&1) || { echo "$out"; echo "FAIL: test -p liba errored"; exit 1; } +echo "$out" | grep -q "1 passed" || { echo "$out"; echo "FAIL: liba test did not pass"; exit 1; } +echo "$out" | grep -qi "testing member 'libb'" && { echo "FAIL: -p liba also ran libb"; exit 1; } + +# 2. --workspace runs BOTH members with no duplicate-stem error. +out=$("$MCPP" test --workspace 2>&1) || { echo "$out"; echo "FAIL: test --workspace errored"; exit 1; } +echo "$out" | grep -qi "duplicate test name" && { echo "$out"; echo "FAIL: duplicate-stem bug not fixed"; exit 1; } +echo "$out" | grep -qi "testing member 'liba'" || { echo "$out"; echo "FAIL: liba not tested"; exit 1; } +echo "$out" | grep -qi "testing member 'libb'" || { echo "$out"; echo "FAIL: libb not tested"; exit 1; } + +# 3. Bare `mcpp test` at a virtual workspace root fans out the same way. +out=$("$MCPP" test 2>&1) || { echo "$out"; echo "FAIL: bare test at ws root errored"; exit 1; } +echo "$out" | grep -qi "duplicate test name" && { echo "FAIL: bare test still collides"; exit 1; } +echo "$out" | grep -qi "testing member 'libb'" || { echo "FAIL: bare test did not fan out"; exit 1; } + +# 4. A failing member fails the workspace run (non-zero) but the other still runs. +echo 'int main(){return 1;}' > libb/tests/main.cpp +set +e +out=$("$MCPP" test --workspace 2>&1); rc=$? +set -e +[ "$rc" -ne 0 ] || { echo "$out"; echo "FAIL: failing member did not fail the run"; exit 1; } +echo "$out" | grep -qi "member(s) failed: libb" || { echo "$out"; echo "FAIL: no per-member failure summary"; exit 1; } +echo "$out" | grep -qi "testing member 'liba'" || { echo "FAIL: did not continue past failure"; exit 1; } + +echo "OK"