Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 241 additions & 0 deletions .agents/docs/2026-06-30-workspace-test-and-zero-shell-index-design.md
Original file line number Diff line number Diff line change
@@ -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 <member>`,
`mcpp build|test --workspace`, and fix bare `mcpp test` at a workspace root.
- **Phase 2 (repo `mcpp-index`)** — turn each `tests/<lib>/` 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 <member>` 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::vector<MemberRef>, std::string>
select_members(const manifest::Manifest& root, const std::filesystem::path& rootDir,
const BuildOverrides& ov, bool wantAll);
}
```

- `-p <name>` → 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 <m>` | member `<m>` | member `<m>` | 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/<distinct>.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 <lib>` 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 <cstdio>
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/<m>.gcm` (GCC C++ finds modules only relative to the compile
CWD — the named `-fmodule-file=mcpp=<path>` 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=<pcm>`
(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.
21 changes: 18 additions & 3 deletions docs/06-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
18 changes: 15 additions & 3 deletions docs/zh/06-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. 目录布局

工作空间推荐的目录布局:
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
6 changes: 4 additions & 2 deletions src/build/build_program.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -299,15 +299,17 @@ std::expected<void, std::string> 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));
Expand Down
21 changes: 17 additions & 4 deletions src/build/execute.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -426,8 +426,21 @@ export int run_tests(std::span<const std::string> 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;
Expand All @@ -447,8 +460,8 @@ export int run_tests(std::span<const std::string> 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));
}

Expand Down
Loading
Loading